1use lazy_static::lazy_static;
4use regex::Regex;
5use reqwest::header;
6use serde_json::{self};
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10use std::process::Command;
11use thiserror::Error;
12
13#[derive(Error, Debug)]
14pub enum GithubError {
15 #[error("HTTP error: {0}")]
16 RequestError(#[from] reqwest::Error),
17
18 #[error("IO error: {0}")]
19 IoError(#[from] std::io::Error),
20
21 #[error("Failed to parse Git repository URL: {0}")]
22 GitParseError(String),
23
24 #[error("GitHub token not found. Please set GITHUB_TOKEN environment variable")]
25 TokenNotFound,
26
27 #[error("API error: {status} - {message}")]
28 ApiError { status: u16, message: String },
29}
30
31#[derive(Debug, Clone)]
33pub struct RepoInfo {
34 pub owner: String,
35 pub repo: String,
36 pub default_branch: String,
37}
38
39lazy_static! {
40 static ref GITHUB_REPO_REGEX: Regex =
41 Regex::new(r"(?:https://github\.com/|git@github\.com:)([^/]+)/([^/.]+)(?:\.git)?")
42 .expect("Failed to compile GitHub repo regex - this is a critical error");
43}
44
45pub fn get_repo_info() -> Result<RepoInfo, GithubError> {
47 let output = Command::new("git")
48 .args(["remote", "get-url", "origin"])
49 .output()
50 .map_err(|e| GithubError::GitParseError(format!("Failed to execute git command: {}", e)))?;
51
52 if !output.status.success() {
53 return Err(GithubError::GitParseError(
54 "Failed to get git origin URL. Are you in a git repository?".to_string(),
55 ));
56 }
57
58 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
59
60 if let Some(captures) = GITHUB_REPO_REGEX.captures(&url) {
61 let owner = captures
62 .get(1)
63 .ok_or_else(|| {
64 GithubError::GitParseError("Unable to extract owner from GitHub URL".to_string())
65 })?
66 .as_str()
67 .to_string();
68
69 let repo = captures
70 .get(2)
71 .ok_or_else(|| {
72 GithubError::GitParseError(
73 "Unable to extract repo name from GitHub 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 GithubError::GitParseError(format!("Failed to execute git command: {}", e))
85 })?;
86
87 if !branch_output.status.success() {
88 return Err(GithubError::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 owner,
99 repo,
100 default_branch,
101 })
102 } else {
103 Err(GithubError::GitParseError(format!(
104 "URL '{}' is not a valid GitHub repository URL",
105 url
106 )))
107 }
108}
109
110pub async fn list_workflows(_repo_info: &RepoInfo) -> Result<Vec<String>, GithubError> {
112 let workflows_dir = Path::new(".github/workflows");
113
114 if !workflows_dir.exists() {
115 return Err(GithubError::IoError(std::io::Error::new(
116 std::io::ErrorKind::NotFound,
117 "Workflows directory not found",
118 )));
119 }
120
121 let mut workflow_names = Vec::new();
122
123 for entry in fs::read_dir(workflows_dir)? {
124 let entry = entry?;
125 let path = entry.path();
126
127 if path.is_file()
128 && path
129 .extension()
130 .is_some_and(|ext| ext == "yml" || ext == "yaml")
131 {
132 if let Some(file_name) = path.file_stem() {
133 if let Some(name) = file_name.to_str() {
134 workflow_names.push(name.to_string());
135 }
136 }
137 }
138 }
139
140 Ok(workflow_names)
141}
142
143pub async fn trigger_workflow(
145 workflow_name: &str,
146 branch: Option<&str>,
147 inputs: Option<HashMap<String, String>>,
148) -> Result<(), GithubError> {
149 let token = std::env::var("GITHUB_TOKEN").map_err(|_| GithubError::TokenNotFound)?;
151
152 let trimmed_token = token.trim();
154
155 let token_header = header::HeaderValue::from_str(&format!("Bearer {}", trimmed_token))
157 .map_err(|_| GithubError::GitParseError("Invalid token format".to_string()))?;
158
159 let repo_info = get_repo_info()?;
161 println!("Repository: {}/{}", repo_info.owner, repo_info.repo);
162
163 let branch_ref = branch.unwrap_or(&repo_info.default_branch);
165 println!("Using branch: {}", branch_ref);
166
167 let workflow_name = if workflow_name.contains('/') {
169 Path::new(workflow_name)
170 .file_stem()
171 .and_then(|s| s.to_str())
172 .ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))?
173 } else {
174 workflow_name
175 };
176
177 println!("Using workflow name: {}", workflow_name);
178
179 let mut payload = serde_json::json!({
181 "ref": branch_ref
182 });
183
184 if let Some(input_map) = inputs {
186 payload["inputs"] = serde_json::json!(input_map);
187 println!("With inputs: {:?}", input_map);
188 }
189
190 let url = format!(
192 "https://api.github.com/repos/{}/{}/actions/workflows/{}.yml/dispatches",
193 repo_info.owner, repo_info.repo, workflow_name
194 );
195
196 println!("Triggering workflow at URL: {}", url);
197
198 let client = reqwest::Client::new();
200
201 let response = client
203 .post(&url)
204 .header(header::AUTHORIZATION, token_header)
205 .header(header::ACCEPT, "application/vnd.github.v3+json")
206 .header(header::CONTENT_TYPE, "application/json")
207 .header(header::USER_AGENT, "wrkflw-cli")
208 .json(&payload)
209 .send()
210 .await
211 .map_err(GithubError::RequestError)?;
212
213 if !response.status().is_success() {
214 let status = response.status().as_u16();
215 let error_message = response
216 .text()
217 .await
218 .unwrap_or_else(|_| format!("Unknown error (HTTP {})", status));
219
220 let error_details = if status == 500 {
222 "Internal server error from GitHub. This could be due to:\n\
223 1. The workflow file doesn't exist in the repository\n\
224 2. The GitHub token doesn't have sufficient permissions\n\
225 3. There's an issue with the workflow file itself\n\
226 Please check:\n\
227 - The workflow file exists at .github/workflows/rust.yml\n\
228 - Your GitHub token has the 'workflow' scope\n\
229 - The workflow file is valid YAML"
230 } else {
231 &error_message
232 };
233
234 return Err(GithubError::ApiError {
235 status,
236 message: error_details.to_string(),
237 });
238 }
239
240 println!("Workflow triggered successfully!");
241 println!(
242 "View runs at: https://github.com/{}/{}/actions/workflows/{}.yml",
243 repo_info.owner, repo_info.repo, workflow_name
244 );
245
246 match list_recent_workflow_runs(&repo_info, workflow_name, &token).await {
248 Ok(runs) => {
249 if !runs.is_empty() {
250 println!("\nRecent runs of this workflow:");
251 for run in runs.iter().take(3) {
252 println!(
253 "- Run #{} ({}): {}",
254 run.get("id").and_then(|id| id.as_u64()).unwrap_or(0),
255 run.get("status")
256 .and_then(|s| s.as_str())
257 .unwrap_or("unknown"),
258 run.get("html_url").and_then(|u| u.as_str()).unwrap_or("")
259 );
260 }
261 } else {
262 println!("\nNo recent runs found. The workflow might still be initializing.");
263 println!(
264 "Check GitHub UI in a few moments: https://github.com/{}/{}/actions",
265 repo_info.owner, repo_info.repo
266 );
267 }
268 }
269 Err(e) => {
270 println!("\nCould not fetch recent workflow runs: {}", e);
271 println!("This doesn't mean the trigger failed - check GitHub UI: https://github.com/{}/{}/actions",
272 repo_info.owner, repo_info.repo);
273 }
274 }
275
276 Ok(())
277}
278
279async fn list_recent_workflow_runs(
281 repo_info: &RepoInfo,
282 workflow_name: &str,
283 token: &str,
284) -> Result<Vec<serde_json::Value>, GithubError> {
285 let workflow_name = if workflow_name.contains('/') {
287 Path::new(workflow_name)
288 .file_stem()
289 .and_then(|s| s.to_str())
290 .ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))?
291 } else {
292 workflow_name
293 };
294
295 let url = format!(
297 "https://api.github.com/repos/{}/{}/actions/workflows/{}.yml/runs?per_page=5",
298 repo_info.owner, repo_info.repo, workflow_name
299 );
300
301 let curl_output = Command::new("curl")
302 .arg("-s")
303 .arg("-H")
304 .arg(format!("Authorization: Bearer {}", token))
305 .arg("-H")
306 .arg("Accept: application/vnd.github.v3+json")
307 .arg(&url)
308 .output()
309 .map_err(|e| GithubError::GitParseError(format!("Failed to execute curl: {}", e)))?;
310
311 if !curl_output.status.success() {
312 let error_message = String::from_utf8_lossy(&curl_output.stderr).to_string();
313 return Err(GithubError::GitParseError(format!(
314 "Failed to list workflow runs: {}",
315 error_message
316 )));
317 }
318
319 let response_body = String::from_utf8_lossy(&curl_output.stdout).to_string();
320 let parsed: serde_json::Value = serde_json::from_str(&response_body)
321 .map_err(|e| GithubError::GitParseError(format!("Failed to parse workflow runs: {}", e)))?;
322
323 if let Some(workflow_runs) = parsed.get("workflow_runs").and_then(|wr| wr.as_array()) {
325 Ok(workflow_runs.clone())
326 } else {
327 Ok(Vec::new())
328 }
329}