lmrc_gitlab/
adapter.rs

1//! # GitLab Adapter
2//!
3//! Adapter implementation that wraps the `lmrc-gitlab` manager and implements
4//! the `GitProvider` port trait from `lmrc-ports`.
5//!
6//! This adapter allows the GitLab manager to be used interchangeably with
7//! other Git/CI providers in the LMRC Stack hexagonal architecture.
8
9use async_trait::async_trait;
10use crate::{GitLabClient, GitLabError, models};
11use lmrc_ports::{
12    CiVariable, CiVariableRequest, GitProvider, PipelineRun, PortError, PortResult, Repository,
13};
14
15/// GitLab adapter implementing the GitProvider port
16pub struct GitLabAdapter {
17    client: GitLabClient,
18    url: String,
19}
20
21impl GitLabAdapter {
22    /// Create a new GitLab adapter
23    ///
24    /// # Arguments
25    ///
26    /// * `url` - GitLab instance URL (e.g., "https://gitlab.com")
27    /// * `token` - GitLab API personal access token
28    ///
29    /// # Errors
30    ///
31    /// Returns error if the client cannot be initialized
32    pub fn new(url: &str, token: &str) -> PortResult<Self> {
33        let client = GitLabClient::new(url, token)
34            .map_err(|e| PortError::InvalidConfiguration(format!("Failed to create GitLab client: {}", e)))?;
35
36        Ok(Self {
37            client,
38            url: url.to_string(),
39        })
40    }
41
42    /// Create adapter from environment variables
43    ///
44    /// Reads:
45    /// - `GITLAB_URL` - GitLab instance URL (default: https://gitlab.com)
46    /// - `GITLAB_TOKEN` - GitLab API token (required)
47    ///
48    /// # Errors
49    ///
50    /// Returns error if required environment variables are missing
51    pub fn from_env() -> PortResult<Self> {
52        let url = std::env::var("GITLAB_URL").unwrap_or_else(|_| "https://gitlab.com".to_string());
53
54        let token = std::env::var("GITLAB_TOKEN").map_err(|_| {
55            PortError::InvalidConfiguration(
56                "GITLAB_TOKEN environment variable is required".to_string(),
57            )
58        })?;
59
60        Self::new(&url, &token)
61    }
62}
63
64/// Convert GitLabError to PortError
65fn convert_error(err: GitLabError) -> PortError {
66    match err {
67        GitLabError::Authentication(msg) => {
68            PortError::InvalidConfiguration(format!("GitLab authentication failed: {}", msg))
69        }
70        GitLabError::NotFound { resource, id } => PortError::NotFound {
71            resource_type: resource,
72            resource_id: id,
73        },
74        GitLabError::Api(msg) => PortError::OperationFailed(format!("GitLab API error: {}", msg)),
75        GitLabError::Http(e) => PortError::NetworkError(format!("HTTP error: {}", e)),
76        GitLabError::Serialization(e) => {
77            PortError::OperationFailed(format!("Serialization error: {}", e))
78        }
79        GitLabError::RateLimit { retry_after } => PortError::OperationFailed(format!(
80            "Rate limit exceeded, retry after: {:?} seconds",
81            retry_after
82        )),
83        GitLabError::PermissionDenied(msg) => {
84            PortError::OperationFailed(format!("Permission denied: {}", msg))
85        }
86        GitLabError::InvalidInput { field, message } => {
87            PortError::InvalidConfiguration(format!("Invalid input for {}: {}", field, message))
88        }
89        GitLabError::Timeout { seconds } => {
90            PortError::OperationFailed(format!("Request timed out after {} seconds", seconds))
91        }
92        GitLabError::Conflict(msg) => PortError::AlreadyExists {
93            resource_type: "Resource".to_string(),
94            resource_id: msg,
95        },
96        GitLabError::ServerError(msg) => {
97            PortError::OperationFailed(format!("GitLab server error: {}", msg))
98        }
99        GitLabError::Unexpected(msg) => PortError::OperationFailed(format!("Unexpected error: {}", msg)),
100        GitLabError::Config(msg) => PortError::InvalidConfiguration(msg),
101    }
102}
103
104/// Convert GitLab Variable to port CiVariable
105fn convert_to_port_variable(var: models::Variable) -> CiVariable {
106    CiVariable {
107        key: var.key,
108        value: var.value,
109        protected: var.protected,
110        masked: var.masked,
111    }
112}
113
114/// Convert CiVariableRequest to GitLab VariableOptions
115fn convert_variable_request(request: &CiVariableRequest) -> models::VariableOptions {
116    models::VariableOptions::new()
117        .protected(request.protected)
118        .masked(request.masked)
119}
120
121/// Convert GitLab PipelineStatus to port PipelineStatus
122fn convert_pipeline_status(status: models::PipelineStatus) -> lmrc_ports::PipelineStatus {
123    use lmrc_ports::PipelineStatus;
124    match status {
125        models::PipelineStatus::Pending => PipelineStatus::Pending,
126        models::PipelineStatus::Running => PipelineStatus::Running,
127        models::PipelineStatus::Success => PipelineStatus::Success,
128        models::PipelineStatus::Failed => PipelineStatus::Failed,
129        models::PipelineStatus::Canceled => PipelineStatus::Canceled,
130        models::PipelineStatus::Skipped => PipelineStatus::Skipped,
131        _ => PipelineStatus::Failed, // Map unknown statuses to Failed
132    }
133}
134
135#[async_trait]
136impl GitProvider for GitLabAdapter {
137    async fn get_repository(&self, project_id: &str) -> PortResult<Repository> {
138        // GitLab uses project paths like "group/project" or numeric IDs
139        // For now, we'll return a minimal Repository struct
140        // TODO: Implement actual project API call when needed
141        Ok(Repository {
142            id: project_id.to_string(),
143            name: project_id.split('/').next_back().unwrap_or(project_id).to_string(),
144            url: format!("{}/{}", self.url, project_id),
145            ssh_url: format!("git@{}:{}.git",
146                self.url.trim_start_matches("https://"),
147                project_id
148            ),
149            default_branch: "main".to_string(), // Default assumption
150        })
151    }
152
153    async fn create_ci_variable(
154        &self,
155        project_id: &str,
156        request: CiVariableRequest,
157    ) -> PortResult<CiVariable> {
158        let opts = convert_variable_request(&request);
159
160        let var = self
161            .client
162            .variables(project_id)
163            .create(&request.key, &request.value, opts)
164            .await
165            .map_err(convert_error)?;
166
167        Ok(convert_to_port_variable(var))
168    }
169
170    async fn update_ci_variable(
171        &self,
172        project_id: &str,
173        key: &str,
174        request: CiVariableRequest,
175    ) -> PortResult<CiVariable> {
176        let opts = convert_variable_request(&request);
177
178        let var = self
179            .client
180            .variables(project_id)
181            .update(key, &request.value, opts)
182            .await
183            .map_err(convert_error)?;
184
185        Ok(convert_to_port_variable(var))
186    }
187
188    async fn list_ci_variables(&self, project_id: &str) -> PortResult<Vec<CiVariable>> {
189        let vars = self
190            .client
191            .variables(project_id)
192            .list()
193            .await
194            .map_err(convert_error)?;
195
196        Ok(vars.into_iter().map(convert_to_port_variable).collect())
197    }
198
199    async fn delete_ci_variable(&self, project_id: &str, key: &str) -> PortResult<()> {
200        self.client
201            .variables(project_id)
202            .delete(key)
203            .await
204            .map_err(convert_error)?;
205
206        Ok(())
207    }
208
209    async fn trigger_pipeline(
210        &self,
211        project_id: &str,
212        reference: &str,
213    ) -> PortResult<PipelineRun> {
214        let pipeline = self
215            .client
216            .project(project_id)
217            .create_pipeline()
218            .ref_name(reference)
219            .trigger()
220            .await
221            .map_err(convert_error)?;
222
223        Ok(PipelineRun {
224            id: pipeline.id.to_string(),
225            status: convert_pipeline_status(pipeline.status),
226            reference: pipeline.ref_name,
227            web_url: pipeline.web_url,
228        })
229    }
230
231    async fn get_pipeline(
232        &self,
233        project_id: &str,
234        pipeline_id: &str,
235    ) -> PortResult<PipelineRun> {
236        let pipeline_id_num = pipeline_id.parse::<u64>().map_err(|_| {
237            PortError::InvalidConfiguration(format!("Invalid pipeline ID: {}", pipeline_id))
238        })?;
239
240        let pipeline = self
241            .client
242            .pipeline(project_id, pipeline_id_num)
243            .get()
244            .await
245            .map_err(convert_error)?;
246
247        Ok(PipelineRun {
248            id: pipeline.id.to_string(),
249            status: convert_pipeline_status(pipeline.status),
250            reference: pipeline.ref_name,
251            web_url: pipeline.web_url,
252        })
253    }
254}