lmrc-gitlab 0.3.16

GitLab API client library for the LMRC Stack - comprehensive Rust library for programmatic control of GitLab via its API
Documentation
//! # GitLab Adapter
//!
//! Adapter implementation that wraps the `lmrc-gitlab` manager and implements
//! the `GitProvider` port trait from `lmrc-ports`.
//!
//! This adapter allows the GitLab manager to be used interchangeably with
//! other Git/CI providers in the LMRC Stack hexagonal architecture.

use async_trait::async_trait;
use crate::{GitLabClient, GitLabError, models};
use lmrc_ports::{
    CiVariable, CiVariableRequest, GitProvider, PipelineRun, PortError, PortResult, Repository,
};

/// GitLab adapter implementing the GitProvider port
pub struct GitLabAdapter {
    client: GitLabClient,
    url: String,
}

impl GitLabAdapter {
    /// Create a new GitLab adapter
    ///
    /// # Arguments
    ///
    /// * `url` - GitLab instance URL (e.g., "https://gitlab.com")
    /// * `token` - GitLab API personal access token
    ///
    /// # Errors
    ///
    /// Returns error if the client cannot be initialized
    pub fn new(url: &str, token: &str) -> PortResult<Self> {
        let client = GitLabClient::new(url, token)
            .map_err(|e| PortError::InvalidConfiguration(format!("Failed to create GitLab client: {}", e)))?;

        Ok(Self {
            client,
            url: url.to_string(),
        })
    }

    /// Create adapter from environment variables
    ///
    /// Reads:
    /// - `GITLAB_URL` - GitLab instance URL (default: https://gitlab.com)
    /// - `GITLAB_TOKEN` - GitLab API token (required)
    ///
    /// # Errors
    ///
    /// Returns error if required environment variables are missing
    pub fn from_env() -> PortResult<Self> {
        let url = std::env::var("GITLAB_URL").unwrap_or_else(|_| "https://gitlab.com".to_string());

        let token = std::env::var("GITLAB_TOKEN").map_err(|_| {
            PortError::InvalidConfiguration(
                "GITLAB_TOKEN environment variable is required".to_string(),
            )
        })?;

        Self::new(&url, &token)
    }
}

/// Convert GitLabError to PortError
fn convert_error(err: GitLabError) -> PortError {
    match err {
        GitLabError::Authentication(msg) => {
            PortError::InvalidConfiguration(format!("GitLab authentication failed: {}", msg))
        }
        GitLabError::NotFound { resource, id } => PortError::NotFound {
            resource_type: resource,
            resource_id: id,
        },
        GitLabError::Api(msg) => PortError::OperationFailed(format!("GitLab API error: {}", msg)),
        GitLabError::Http(e) => PortError::NetworkError(format!("HTTP error: {}", e)),
        GitLabError::Serialization(e) => {
            PortError::OperationFailed(format!("Serialization error: {}", e))
        }
        GitLabError::RateLimit { retry_after } => PortError::OperationFailed(format!(
            "Rate limit exceeded, retry after: {:?} seconds",
            retry_after
        )),
        GitLabError::PermissionDenied(msg) => {
            PortError::OperationFailed(format!("Permission denied: {}", msg))
        }
        GitLabError::InvalidInput { field, message } => {
            PortError::InvalidConfiguration(format!("Invalid input for {}: {}", field, message))
        }
        GitLabError::Timeout { seconds } => {
            PortError::OperationFailed(format!("Request timed out after {} seconds", seconds))
        }
        GitLabError::Conflict(msg) => PortError::AlreadyExists {
            resource_type: "Resource".to_string(),
            resource_id: msg,
        },
        GitLabError::ServerError(msg) => {
            PortError::OperationFailed(format!("GitLab server error: {}", msg))
        }
        GitLabError::Unexpected(msg) => PortError::OperationFailed(format!("Unexpected error: {}", msg)),
        GitLabError::Config(msg) => PortError::InvalidConfiguration(msg),
    }
}

/// Convert GitLab Variable to port CiVariable
fn convert_to_port_variable(var: models::Variable) -> CiVariable {
    CiVariable {
        key: var.key,
        value: var.value,
        protected: var.protected,
        masked: var.masked,
    }
}

/// Convert CiVariableRequest to GitLab VariableOptions
fn convert_variable_request(request: &CiVariableRequest) -> models::VariableOptions {
    models::VariableOptions::new()
        .protected(request.protected)
        .masked(request.masked)
}

/// Convert GitLab PipelineStatus to port PipelineStatus
fn convert_pipeline_status(status: models::PipelineStatus) -> lmrc_ports::PipelineStatus {
    use lmrc_ports::PipelineStatus;
    match status {
        models::PipelineStatus::Pending => PipelineStatus::Pending,
        models::PipelineStatus::Running => PipelineStatus::Running,
        models::PipelineStatus::Success => PipelineStatus::Success,
        models::PipelineStatus::Failed => PipelineStatus::Failed,
        models::PipelineStatus::Canceled => PipelineStatus::Canceled,
        models::PipelineStatus::Skipped => PipelineStatus::Skipped,
        _ => PipelineStatus::Failed, // Map unknown statuses to Failed
    }
}

#[async_trait]
impl GitProvider for GitLabAdapter {
    async fn get_repository(&self, project_id: &str) -> PortResult<Repository> {
        // GitLab uses project paths like "group/project" or numeric IDs
        // For now, we'll return a minimal Repository struct
        // TODO: Implement actual project API call when needed
        Ok(Repository {
            id: project_id.to_string(),
            name: project_id.split('/').next_back().unwrap_or(project_id).to_string(),
            url: format!("{}/{}", self.url, project_id),
            ssh_url: format!("git@{}:{}.git",
                self.url.trim_start_matches("https://"),
                project_id
            ),
            default_branch: "main".to_string(), // Default assumption
        })
    }

    async fn create_ci_variable(
        &self,
        project_id: &str,
        request: CiVariableRequest,
    ) -> PortResult<CiVariable> {
        let opts = convert_variable_request(&request);

        let var = self
            .client
            .variables(project_id)
            .create(&request.key, &request.value, opts)
            .await
            .map_err(convert_error)?;

        Ok(convert_to_port_variable(var))
    }

    async fn update_ci_variable(
        &self,
        project_id: &str,
        key: &str,
        request: CiVariableRequest,
    ) -> PortResult<CiVariable> {
        let opts = convert_variable_request(&request);

        let var = self
            .client
            .variables(project_id)
            .update(key, &request.value, opts)
            .await
            .map_err(convert_error)?;

        Ok(convert_to_port_variable(var))
    }

    async fn list_ci_variables(&self, project_id: &str) -> PortResult<Vec<CiVariable>> {
        let vars = self
            .client
            .variables(project_id)
            .list()
            .await
            .map_err(convert_error)?;

        Ok(vars.into_iter().map(convert_to_port_variable).collect())
    }

    async fn delete_ci_variable(&self, project_id: &str, key: &str) -> PortResult<()> {
        self.client
            .variables(project_id)
            .delete(key)
            .await
            .map_err(convert_error)?;

        Ok(())
    }

    async fn trigger_pipeline(
        &self,
        project_id: &str,
        reference: &str,
    ) -> PortResult<PipelineRun> {
        let pipeline = self
            .client
            .project(project_id)
            .create_pipeline()
            .ref_name(reference)
            .trigger()
            .await
            .map_err(convert_error)?;

        Ok(PipelineRun {
            id: pipeline.id.to_string(),
            status: convert_pipeline_status(pipeline.status),
            reference: pipeline.ref_name,
            web_url: pipeline.web_url,
        })
    }

    async fn get_pipeline(
        &self,
        project_id: &str,
        pipeline_id: &str,
    ) -> PortResult<PipelineRun> {
        let pipeline_id_num = pipeline_id.parse::<u64>().map_err(|_| {
            PortError::InvalidConfiguration(format!("Invalid pipeline ID: {}", pipeline_id))
        })?;

        let pipeline = self
            .client
            .pipeline(project_id, pipeline_id_num)
            .get()
            .await
            .map_err(convert_error)?;

        Ok(PipelineRun {
            id: pipeline.id.to_string(),
            status: convert_pipeline_status(pipeline.status),
            reference: pipeline.ref_name,
            web_url: pipeline.web_url,
        })
    }
}