pipedash-plugin-gitlab 0.4.0

GitLab CI/CD plugin for Pipedash
Documentation
use std::sync::OnceLock;

use pipedash_plugin_api::{
    check_response,
    map_send_error,
    PaginatedResponse,
    PaginationParams,
    PluginError,
    PluginResult,
    RetryPolicy,
};
use reqwest::Url;

use crate::types::{
    Pipeline,
    PipelineVariable,
    Project,
    TriggerPipelineRequest,
    User,
};

pub struct GitLabClient {
    http_client: std::sync::Arc<reqwest::Client>,
    api_url: String,
    token: String,
    retry_policy: RetryPolicy,
    user_cache: OnceLock<User>,
}

impl GitLabClient {
    pub fn new(
        http_client: std::sync::Arc<reqwest::Client>, api_url: String, token: String,
    ) -> Self {
        Self {
            http_client,
            api_url: api_url.trim_end_matches('/').to_string(),
            token,
            retry_policy: RetryPolicy::default(),
            user_cache: OnceLock::new(),
        }
    }

    pub async fn get_user(&self) -> PluginResult<User> {
        if let Some(user) = self.user_cache.get() {
            return Ok(user.clone());
        }

        let user: User = self
            .retry_policy
            .retry(|| async {
                let response = self.get(&format!("{}/user", self.api_url)).await?;
                Self::json(response).await
            })
            .await?;

        let _ = self.user_cache.set(user.clone());
        Ok(user)
    }

    pub async fn list_groups(&self) -> PluginResult<Vec<pipedash_plugin_api::Organization>> {
        let user = self.get_user().await?;
        let mut orgs = vec![pipedash_plugin_api::Organization {
            id: user.id.to_string(),
            name: user.username.clone(),
            description: Some(user.name.clone()),
        }];

        match self.list_all_groups().await {
            Ok(mut groups) => orgs.append(&mut groups),
            Err(e) => tracing::warn!(error = %e, "Failed to list GitLab groups"),
        }

        Ok(orgs)
    }

    async fn list_all_groups(&self) -> PluginResult<Vec<pipedash_plugin_api::Organization>> {
        let mut groups = Vec::new();
        let mut page = Some(1);

        while let Some(current) = page {
            let (mut items, next_page) = self
                .retry_policy
                .retry(|| async {
                    let response = self
                        .get(&format!(
                            "{}/groups?per_page=100&page={current}",
                            self.api_url
                        ))
                        .await?;
                    let next_page = response
                        .headers()
                        .get("x-next-page")
                        .and_then(|v| v.to_str().ok())
                        .and_then(|s| s.parse::<usize>().ok());
                    let items: Vec<serde_json::Value> = Self::json(response).await?;

                    Ok((
                        items
                            .into_iter()
                            .map(|g| pipedash_plugin_api::Organization {
                                id: g["id"].as_i64().unwrap_or(0).to_string(),
                                name: g["full_path"]
                                    .as_str()
                                    .or_else(|| g["name"].as_str())
                                    .unwrap_or("")
                                    .to_string(),
                                description: g["description"].as_str().map(|s| s.to_string()),
                            })
                            .collect::<Vec<_>>(),
                        next_page,
                    ))
                })
                .await?;
            groups.append(&mut items);
            page = next_page.filter(|next| *next > current);
        }

        Ok(groups)
    }

    pub async fn list_projects(
        &self, params: &PaginationParams,
    ) -> PluginResult<PaginatedResponse<Project>> {
        self.retry_policy
            .retry(|| async {
                let url = format!(
                    "{}/projects?membership=true&per_page={}&page={}",
                    self.api_url, params.page_size, params.page
                );
                self.project_page(&url, params).await
            })
            .await
    }

    pub async fn list_projects_filtered(
        &self, group_id: Option<String>, search: Option<String>, params: &PaginationParams,
    ) -> PluginResult<PaginatedResponse<Project>> {
        let user = self.get_user().await?;
        let is_personal_account = group_id
            .as_ref()
            .map(|gid| gid == &user.id.to_string())
            .unwrap_or(false);

        self.retry_policy
            .retry(|| async {
                let mut url = if let Some(gid) = &group_id {
                    if is_personal_account {
                        format!(
                            "{}/users/{}/projects?per_page={}&page={}",
                            self.api_url, user.id, params.page_size, params.page
                        )
                    } else {
                        format!(
                            "{}/groups/{}/projects?include_subgroups=true&per_page={}&page={}",
                            self.api_url, gid, params.page_size, params.page
                        )
                    }
                } else {
                    format!(
                        "{}/projects?membership=true&per_page={}&page={}",
                        self.api_url, params.page_size, params.page
                    )
                };

                if let Some(search_term) = &search {
                    let encoded = search_term.replace(" ", "%20").replace("&", "%26");
                    url.push_str(&format!("&search={}", encoded));
                }

                self.project_page(&url, params).await
            })
            .await
    }

    pub async fn get_project(&self, project_id: i64) -> PluginResult<Project> {
        self.retry_policy
            .retry(|| async {
                let response = self
                    .get(&format!("{}/projects/{}", self.api_url, project_id))
                    .await?;
                Self::json(response).await
            })
            .await
    }

    pub async fn get_project_pipelines(
        &self, project_id: i64, per_page: usize,
    ) -> PluginResult<Vec<Pipeline>> {
        self.retry_policy
            .retry(|| async {
                let response = self
                    .get(&format!(
                        "{}/projects/{}/pipelines?per_page={}&order_by=updated_at",
                        self.api_url, project_id, per_page
                    ))
                    .await?;
                Self::json(response).await
            })
            .await
    }

    pub async fn get_pipeline(&self, project_id: i64, pipeline_id: i64) -> PluginResult<Pipeline> {
        self.retry_policy
            .retry(|| async {
                let response = self
                    .get(&format!(
                        "{}/projects/{}/pipelines/{}",
                        self.api_url, project_id, pipeline_id
                    ))
                    .await?;
                Self::json(response).await
            })
            .await
    }

    pub async fn trigger_pipeline(
        &self, project_id: i64, ref_name: String, variables: Option<Vec<PipelineVariable>>,
    ) -> PluginResult<Pipeline> {
        let url = Self::parse_url(&format!(
            "{}/projects/{}/pipeline",
            self.api_url, project_id
        ))?;
        let request_body = TriggerPipelineRequest {
            ref_name,
            variables,
        };

        let response = self
            .retry_policy
            .retry_unsent(&url, || {
                self.http_client
                    .post(url.clone())
                    .header("PRIVATE-TOKEN", &self.token)
                    .json(&request_body)
                    .send()
            })
            .await?;

        Self::json(check_response(response).await?).await
    }

    pub async fn cancel_pipeline(
        &self, project_id: i64, pipeline_id: i64,
    ) -> PluginResult<Pipeline> {
        let url = Self::parse_url(&format!(
            "{}/projects/{}/pipelines/{}/cancel",
            self.api_url, project_id, pipeline_id
        ))?;

        let response = self
            .retry_policy
            .retry_unsent(&url, || {
                self.http_client
                    .post(url.clone())
                    .header("PRIVATE-TOKEN", &self.token)
                    .send()
            })
            .await?;

        Self::json(check_response(response).await?).await
    }

    async fn project_page(
        &self, url: &str, params: &PaginationParams,
    ) -> PluginResult<PaginatedResponse<Project>> {
        let response = self.get(url).await?;
        let total_count = response
            .headers()
            .get("x-total")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.parse::<usize>().ok());

        let items: Vec<Project> = Self::json(response).await?;
        let count = total_count.unwrap_or(items.len());

        Ok(PaginatedResponse::new(
            items,
            params.page,
            params.page_size,
            count,
        ))
    }

    fn parse_url(url: &str) -> PluginResult<Url> {
        Url::parse(url).map_err(|e| PluginError::InvalidConfig(format!("Invalid GitLab URL: {e}")))
    }

    async fn get(&self, url: &str) -> PluginResult<reqwest::Response> {
        let url = Self::parse_url(url)?;
        let response = self
            .http_client
            .get(url.clone())
            .header("PRIVATE-TOKEN", &self.token)
            .send()
            .await
            .map_err(|e| map_send_error(e, &url))?;
        check_response(response).await
    }

    async fn json<T: serde::de::DeserializeOwned>(response: reqwest::Response) -> PluginResult<T> {
        let url = response.url().clone();
        response.json::<T>().await.map_err(|e| {
            let e = e.without_url();
            tracing::error!(error = %e, "Failed to deserialize GitLab response");
            PluginError::ApiError(format!(
                "Failed to parse GitLab API response from {}: {e}",
                pipedash_plugin_api::redact_url(&url),
            ))
        })
    }
}