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
80        let branch_output = Command::new("git")
81            .args(["rev-parse", "--abbrev-ref", "HEAD"])
82            .output()
83            .map_err(|e| {
84                GitlabError::GitParseError(format!("Failed to execute git command: {}", e))
85            })?;
86
87        if !branch_output.status.success() {
88            return Err(GitlabError::GitParseError(
89                "Failed to get current branch".to_string(),
90            ));
91        }
92
93        let default_branch = String::from_utf8_lossy(&branch_output.stdout)
94            .trim()
95            .to_string();
96
97        Ok(RepoInfo {
98            namespace,
99            project,
100            default_branch,
101        })
102    } else {
103        Err(GitlabError::GitParseError(format!(
104            "URL '{}' is not a valid GitLab repository URL",
105            url
106        )))
107    }
108}
109
110/// Get the list of available pipeline files in the repository
111pub async fn list_pipelines(_repo_info: &RepoInfo) -> Result<Vec<String>, GitlabError> {
112    // GitLab CI/CD pipelines are defined in .gitlab-ci.yml files
113    let pipeline_file = Path::new(".gitlab-ci.yml");
114
115    if !pipeline_file.exists() {
116        return Err(GitlabError::IoError(std::io::Error::new(
117            std::io::ErrorKind::NotFound,
118            "GitLab CI/CD pipeline file not found (.gitlab-ci.yml)",
119        )));
120    }
121
122    // In GitLab, there's typically a single pipeline file with multiple jobs
123    // Return a list with just that file name
124    Ok(vec!["gitlab-ci".to_string()])
125}
126
127/// Trigger a pipeline on GitLab
128pub async fn trigger_pipeline(
129    branch: Option<&str>,
130    variables: Option<HashMap<String, String>>,
131) -> Result<(), GitlabError> {
132    // Get GitLab token from environment
133    let token = std::env::var("GITLAB_TOKEN").map_err(|_| GitlabError::TokenNotFound)?;
134
135    // Trim the token to remove any leading or trailing whitespace
136    let trimmed_token = token.trim();
137
138    // Get repository information
139    let repo_info = get_repo_info()?;
140    println!(
141        "GitLab Repository: {}/{}",
142        repo_info.namespace, repo_info.project
143    );
144
145    // Prepare the request payload
146    let branch_ref = branch.unwrap_or(&repo_info.default_branch);
147    println!("Using branch: {}", branch_ref);
148
149    // Create simplified payload
150    let mut payload = serde_json::json!({
151        "ref": branch_ref
152    });
153
154    // Add variables if provided
155    if let Some(vars_map) = variables {
156        // GitLab expects variables in a specific format
157        let formatted_vars: Vec<serde_json::Value> = vars_map
158            .iter()
159            .map(|(key, value)| {
160                serde_json::json!({
161                    "key": key,
162                    "value": value
163                })
164            })
165            .collect();
166
167        payload["variables"] = serde_json::json!(formatted_vars);
168        println!("With variables: {:?}", vars_map);
169    }
170
171    // URL encode the namespace and project for use in URL
172    let encoded_namespace = urlencoding::encode(&repo_info.namespace);
173    let encoded_project = urlencoding::encode(&repo_info.project);
174
175    // Send the pipeline trigger request
176    let url = format!(
177        "https://gitlab.com/api/v4/projects/{encoded_namespace}%2F{encoded_project}/pipeline",
178        encoded_namespace = encoded_namespace,
179        encoded_project = encoded_project,
180    );
181
182    println!("Triggering pipeline at URL: {}", url);
183
184    // Create a reqwest client
185    let client = reqwest::Client::new();
186
187    // Send the request using reqwest
188    let response = client
189        .post(&url)
190        .header("PRIVATE-TOKEN", trimmed_token)
191        .header(header::CONTENT_TYPE, "application/json")
192        .json(&payload)
193        .send()
194        .await
195        .map_err(GitlabError::RequestError)?;
196
197    if !response.status().is_success() {
198        let status = response.status().as_u16();
199        let error_message = response
200            .text()
201            .await
202            .unwrap_or_else(|_| format!("Unknown error (HTTP {})", status));
203
204        // Add more detailed error information
205        let error_details = if status == 404 {
206            "Project not found or token doesn't have access to it. This could be due to:\n\
207             1. The project doesn't exist\n\
208             2. The GitLab token doesn't have sufficient permissions\n\
209             Please check:\n\
210             - The repository URL is correct\n\
211             - Your GitLab token has the correct scope (api access)\n\
212             - Your token has access to the project"
213        } else if status == 401 {
214            "Unauthorized. Your GitLab token may be invalid or expired."
215        } else {
216            &error_message
217        };
218
219        return Err(GitlabError::ApiError {
220            status,
221            message: error_details.to_string(),
222        });
223    }
224
225    // Parse response to get pipeline ID
226    let pipeline_info: serde_json::Value = response.json().await?;
227    let pipeline_id = pipeline_info["id"].as_i64().unwrap_or(0);
228    let pipeline_url = format!(
229        "https://gitlab.com/{}/{}/pipelines/{}",
230        repo_info.namespace, repo_info.project, pipeline_id
231    );
232
233    println!("Pipeline triggered successfully!");
234    println!("View pipeline at: {}", pipeline_url);
235
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_parse_gitlab_url_https() {
245        let url = "https://gitlab.com/mygroup/myproject.git";
246        assert!(GITLAB_REPO_REGEX.is_match(url));
247
248        let captures = GITLAB_REPO_REGEX.captures(url).unwrap();
249        assert_eq!(captures.get(1).unwrap().as_str(), "mygroup");
250        assert_eq!(captures.get(2).unwrap().as_str(), "myproject");
251    }
252
253    #[test]
254    fn test_parse_gitlab_url_ssh() {
255        let url = "git@gitlab.com:mygroup/myproject.git";
256        assert!(GITLAB_REPO_REGEX.is_match(url));
257
258        let captures = GITLAB_REPO_REGEX.captures(url).unwrap();
259        assert_eq!(captures.get(1).unwrap().as_str(), "mygroup");
260        assert_eq!(captures.get(2).unwrap().as_str(), "myproject");
261    }
262
263    #[test]
264    fn test_parse_gitlab_url_no_git_extension() {
265        let url = "https://gitlab.com/mygroup/myproject";
266        assert!(GITLAB_REPO_REGEX.is_match(url));
267
268        let captures = GITLAB_REPO_REGEX.captures(url).unwrap();
269        assert_eq!(captures.get(1).unwrap().as_str(), "mygroup");
270        assert_eq!(captures.get(2).unwrap().as_str(), "myproject");
271    }
272
273    #[test]
274    fn test_parse_invalid_url() {
275        let url = "https://github.com/myuser/myrepo.git";
276        assert!(!GITLAB_REPO_REGEX.is_match(url));
277    }
278}