1use 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#[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
43pub 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 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 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
116pub async fn list_pipelines(_repo_info: &RepoInfo) -> Result<Vec<String>, GitlabError> {
118 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 Ok(vec!["gitlab-ci".to_string()])
131}
132
133pub async fn trigger_pipeline(
135 branch: Option<&str>,
136 variables: Option<HashMap<String, String>>,
137) -> Result<(), GitlabError> {
138 let token = std::env::var("GITLAB_TOKEN").map_err(|_| GitlabError::TokenNotFound)?;
140
141 let trimmed_token = token.trim();
143
144 let repo_info = get_repo_info()?;
146 wrkflw_logging::info(&format!(
147 "GitLab Repository: {}/{}",
148 repo_info.namespace, repo_info.project
149 ));
150
151 let branch_ref = branch.unwrap_or(&repo_info.default_branch);
153 wrkflw_logging::info(&format!("Using branch: {}", branch_ref));
154
155 let mut payload = serde_json::json!({
157 "ref": branch_ref
158 });
159
160 if let Some(vars_map) = variables {
162 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 let encoded_namespace = urlencoding::encode(&repo_info.namespace);
179 let encoded_project = urlencoding::encode(&repo_info.project);
180
181 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 let client = reqwest::Client::new();
192
193 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 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 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}