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 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
110pub async fn list_pipelines(_repo_info: &RepoInfo) -> Result<Vec<String>, GitlabError> {
112 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 Ok(vec!["gitlab-ci".to_string()])
125}
126
127pub async fn trigger_pipeline(
129 branch: Option<&str>,
130 variables: Option<HashMap<String, String>>,
131) -> Result<(), GitlabError> {
132 let token = std::env::var("GITLAB_TOKEN").map_err(|_| GitlabError::TokenNotFound)?;
134
135 let trimmed_token = token.trim();
137
138 let repo_info = get_repo_info()?;
140 println!(
141 "GitLab Repository: {}/{}",
142 repo_info.namespace, repo_info.project
143 );
144
145 let branch_ref = branch.unwrap_or(&repo_info.default_branch);
147 println!("Using branch: {}", branch_ref);
148
149 let mut payload = serde_json::json!({
151 "ref": branch_ref
152 });
153
154 if let Some(vars_map) = variables {
156 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 let encoded_namespace = urlencoding::encode(&repo_info.namespace);
173 let encoded_project = urlencoding::encode(&repo_info.project);
174
175 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 let client = reqwest::Client::new();
186
187 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 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 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}