Skip to main content

wrkflw_gitlab/
lib.rs

1// gitlab crate
2
3use lazy_static::lazy_static;
4use regex::Regex;
5use reqwest::header;
6use std::collections::HashMap;
7use std::path::Path;
8use std::process::Command;
9use thiserror::Error;
10
11#[derive(Error, Debug)]
12pub enum GitlabError {
13    #[error("HTTP error: {0}")]
14    RequestError(#[from] reqwest::Error),
15
16    #[error("IO error: {0}")]
17    IoError(#[from] std::io::Error),
18
19    #[error("Failed to parse Git repository URL: {0}")]
20    GitParseError(String),
21
22    #[error("GitLab token not found. Please set GITLAB_TOKEN environment variable")]
23    TokenNotFound,
24
25    #[error("API error: {status} - {message}")]
26    ApiError { status: u16, message: String },
27}
28
29/// Information about a GitLab repository
30#[derive(Debug, Clone)]
31pub struct RepoInfo {
32    pub namespace: String,
33    pub project: String,
34    pub default_branch: String,
35}
36
37lazy_static! {
38    static ref GITLAB_REPO_REGEX: Regex =
39        Regex::new(r"(?:https://gitlab\.com/|git@gitlab\.com:)([^/]+)/([^/.]+)(?:\.git)?")
40            .expect("Failed to compile GitLab repo regex - this is a critical error");
41}
42
43/// Extract repository information from the current git repository for GitLab
44pub fn get_repo_info() -> Result<RepoInfo, GitlabError> {
45    let output = Command::new("git")
46        .args(["remote", "get-url", "origin"])
47        .output()
48        .map_err(|e| GitlabError::GitParseError(format!("Failed to execute git command: {}", e)))?;
49
50    if !output.status.success() {
51        return Err(GitlabError::GitParseError(
52            "Failed to get git origin URL. Are you in a git repository?".to_string(),
53        ));
54    }
55
56    let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
57
58    if let Some(captures) = GITLAB_REPO_REGEX.captures(&url) {
59        let namespace = captures
60            .get(1)
61            .ok_or_else(|| {
62                GitlabError::GitParseError(
63                    "Unable to extract namespace from GitLab URL".to_string(),
64                )
65            })?
66            .as_str()
67            .to_string();
68
69        let project = captures
70            .get(2)
71            .ok_or_else(|| {
72                GitlabError::GitParseError(
73                    "Unable to extract project name from GitLab URL".to_string(),
74                )
75            })?
76            .as_str()
77            .to_string();
78
79        // Get the default branch (try remote HEAD first, fall back to current branch)
80        let default_branch = Command::new("git")
81            .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
82            .output()
83            .ok()
84            .filter(|o| o.status.success())
85            .map(|o| {
86                let full_ref = String::from_utf8_lossy(&o.stdout).trim().to_string();
87                full_ref
88                    .strip_prefix("refs/remotes/origin/")
89                    .unwrap_or(&full_ref)
90                    .to_string()
91            })
92            .unwrap_or_else(|| {
93                // Fall back to current branch
94                Command::new("git")
95                    .args(["rev-parse", "--abbrev-ref", "HEAD"])
96                    .output()
97                    .ok()
98                    .filter(|o| o.status.success())
99                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
100                    .unwrap_or_else(|| "main".to_string())
101            });
102
103        Ok(RepoInfo {
104            namespace,
105            project,
106            default_branch,
107        })
108    } else {
109        Err(GitlabError::GitParseError(format!(
110            "URL '{}' is not a valid GitLab repository URL",
111            url
112        )))
113    }
114}
115
116/// Get the list of available pipeline files in the repository
117pub async fn list_pipelines(_repo_info: &RepoInfo) -> Result<Vec<String>, GitlabError> {
118    // GitLab CI/CD pipelines are defined in .gitlab-ci.yml files
119    let pipeline_file = Path::new(".gitlab-ci.yml");
120
121    if !pipeline_file.exists() {
122        return Err(GitlabError::IoError(std::io::Error::new(
123            std::io::ErrorKind::NotFound,
124            "GitLab CI/CD pipeline file not found (.gitlab-ci.yml)",
125        )));
126    }
127
128    // In GitLab, there's typically a single pipeline file with multiple jobs
129    // Return a list with just that file name
130    Ok(vec!["gitlab-ci".to_string()])
131}
132
133/// Trigger a pipeline on GitLab
134pub async fn trigger_pipeline(
135    branch: Option<&str>,
136    variables: Option<HashMap<String, String>>,
137) -> Result<(), GitlabError> {
138    // Get GitLab token from environment
139    let token = std::env::var("GITLAB_TOKEN").map_err(|_| GitlabError::TokenNotFound)?;
140
141    // Trim the token to remove any leading or trailing whitespace
142    let trimmed_token = token.trim();
143
144    // Get repository information
145    let repo_info = get_repo_info()?;
146    wrkflw_logging::info(&format!(
147        "GitLab Repository: {}/{}",
148        repo_info.namespace, repo_info.project
149    ));
150
151    // Prepare the request payload
152    let branch_ref = branch.unwrap_or(&repo_info.default_branch);
153    wrkflw_logging::info(&format!("Using branch: {}", branch_ref));
154
155    // Create simplified payload
156    let mut payload = serde_json::json!({
157        "ref": branch_ref
158    });
159
160    // Add variables if provided
161    if let Some(vars_map) = variables {
162        // GitLab expects variables in a specific format
163        let formatted_vars: Vec<serde_json::Value> = vars_map
164            .iter()
165            .map(|(key, value)| {
166                serde_json::json!({
167                    "key": key,
168                    "value": value
169                })
170            })
171            .collect();
172
173        payload["variables"] = serde_json::json!(formatted_vars);
174        wrkflw_logging::info(&format!("With variables: {:?}", vars_map));
175    }
176
177    // URL encode the namespace and project for use in URL
178    let encoded_namespace = urlencoding::encode(&repo_info.namespace);
179    let encoded_project = urlencoding::encode(&repo_info.project);
180
181    // Send the pipeline trigger request
182    let url = format!(
183        "https://gitlab.com/api/v4/projects/{encoded_namespace}%2F{encoded_project}/pipeline",
184        encoded_namespace = encoded_namespace,
185        encoded_project = encoded_project,
186    );
187
188    wrkflw_logging::info(&format!("Triggering pipeline at URL: {}", url));
189
190    // Create a reqwest client
191    let client = reqwest::Client::new();
192
193    // Send the request using reqwest
194    let response = client
195        .post(&url)
196        .header("PRIVATE-TOKEN", trimmed_token)
197        .header(header::CONTENT_TYPE, "application/json")
198        .json(&payload)
199        .send()
200        .await
201        .map_err(GitlabError::RequestError)?;
202
203    if !response.status().is_success() {
204        let status = response.status().as_u16();
205        let error_message = response
206            .text()
207            .await
208            .unwrap_or_else(|_| format!("Unknown error (HTTP {})", status));
209
210        // Add more detailed error information
211        let error_details = if status == 404 {
212            "Project not found or token doesn't have access to it. This could be due to:\n\
213             1. The project doesn't exist\n\
214             2. The GitLab token doesn't have sufficient permissions\n\
215             Please check:\n\
216             - The repository URL is correct\n\
217             - Your GitLab token has the correct scope (api access)\n\
218             - Your token has access to the project"
219        } else if status == 401 {
220            "Unauthorized. Your GitLab token may be invalid or expired."
221        } else {
222            &error_message
223        };
224
225        return Err(GitlabError::ApiError {
226            status,
227            message: error_details.to_string(),
228        });
229    }
230
231    // Parse response to get pipeline ID
232    let pipeline_info: serde_json::Value = response.json().await?;
233    let pipeline_id = pipeline_info["id"].as_i64().unwrap_or(0);
234    let pipeline_url = format!(
235        "https://gitlab.com/{}/{}/pipelines/{}",
236        repo_info.namespace, repo_info.project, pipeline_id
237    );
238
239    wrkflw_logging::info("Pipeline triggered successfully!");
240    wrkflw_logging::info(&format!("View pipeline at: {}", pipeline_url));
241
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_parse_gitlab_url_https() {
251        let url = "https://gitlab.com/mygroup/myproject.git";
252        assert!(GITLAB_REPO_REGEX.is_match(url));
253
254        let captures = GITLAB_REPO_REGEX.captures(url).unwrap();
255        assert_eq!(captures.get(1).unwrap().as_str(), "mygroup");
256        assert_eq!(captures.get(2).unwrap().as_str(), "myproject");
257    }
258
259    #[test]
260    fn test_parse_gitlab_url_ssh() {
261        let url = "git@gitlab.com:mygroup/myproject.git";
262        assert!(GITLAB_REPO_REGEX.is_match(url));
263
264        let captures = GITLAB_REPO_REGEX.captures(url).unwrap();
265        assert_eq!(captures.get(1).unwrap().as_str(), "mygroup");
266        assert_eq!(captures.get(2).unwrap().as_str(), "myproject");
267    }
268
269    #[test]
270    fn test_parse_gitlab_url_no_git_extension() {
271        let url = "https://gitlab.com/mygroup/myproject";
272        assert!(GITLAB_REPO_REGEX.is_match(url));
273
274        let captures = GITLAB_REPO_REGEX.captures(url).unwrap();
275        assert_eq!(captures.get(1).unwrap().as_str(), "mygroup");
276        assert_eq!(captures.get(2).unwrap().as_str(), "myproject");
277    }
278
279    #[test]
280    fn test_parse_invalid_url() {
281        let url = "https://github.com/myuser/myrepo.git";
282        assert!(!GITLAB_REPO_REGEX.is_match(url));
283    }
284}