wrkflw_github/
lib.rs

1// github crate
2
3use 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/// Information about a GitHub repository
32#[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
45/// Extract repository information from the current git repository
46pub 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        // 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                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
110/// Get the list of available workflows in the repository
111pub 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
143/// Trigger a workflow on GitHub
144pub async fn trigger_workflow(
145    workflow_name: &str,
146    branch: Option<&str>,
147    inputs: Option<HashMap<String, String>>,
148) -> Result<(), GithubError> {
149    // Get GitHub token from environment
150    let token = std::env::var("GITHUB_TOKEN").map_err(|_| GithubError::TokenNotFound)?;
151
152    // Trim the token to remove any leading or trailing whitespace
153    let trimmed_token = token.trim();
154
155    // Convert token to HeaderValue
156    let token_header = header::HeaderValue::from_str(&format!("Bearer {}", trimmed_token))
157        .map_err(|_| GithubError::GitParseError("Invalid token format".to_string()))?;
158
159    // Get repository information
160    let repo_info = get_repo_info()?;
161    println!("Repository: {}/{}", repo_info.owner, repo_info.repo);
162
163    // Prepare the request payload
164    let branch_ref = branch.unwrap_or(&repo_info.default_branch);
165    println!("Using branch: {}", branch_ref);
166
167    // Extract just the workflow name from the path if it's a full path
168    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    // Create simplified payload
180    let mut payload = serde_json::json!({
181        "ref": branch_ref
182    });
183
184    // Add inputs if provided
185    if let Some(input_map) = inputs {
186        payload["inputs"] = serde_json::json!(input_map);
187        println!("With inputs: {:?}", input_map);
188    }
189
190    // Send the workflow_dispatch event
191    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    // Create a reqwest client
199    let client = reqwest::Client::new();
200
201    // Send the request using reqwest
202    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        // Add more detailed error information
221        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    // Attempt to verify the workflow was actually triggered
247    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
279/// List recent workflow runs for a specific workflow
280async fn list_recent_workflow_runs(
281    repo_info: &RepoInfo,
282    workflow_name: &str,
283    token: &str,
284) -> Result<Vec<serde_json::Value>, GithubError> {
285    // Extract just the workflow name from the path if it's a full path
286    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    // Get recent workflow runs via GitHub API
296    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    // Extract the workflow runs from the response
324    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}