Skip to main content

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 (try remote HEAD first, fall back to current branch)
80        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                // Fall back to current branch
94                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            owner,
105            repo,
106            default_branch,
107        })
108    } else {
109        Err(GithubError::GitParseError(format!(
110            "URL '{}' is not a valid GitHub repository URL",
111            url
112        )))
113    }
114}
115
116/// Normalize a user-facing workflow identifier into the path segment
117/// GitHub's `workflow_dispatch` endpoint expects as `{workflow_file_name}`
118/// in `/repos/{owner}/{repo}/actions/workflows/{workflow_file_name}/dispatches`.
119///
120/// - Drops any directory prefix: `"release/prod.yml"` → `"prod.yml"`.
121/// - Preserves an existing `.yml` or `.yaml` suffix so workflows stored
122///   as `.yaml` don't have `.yml` tacked on.
123/// - Appends `.yml` when no extension is present so the result is
124///   always a valid filename reference.
125/// - Returns `None` for inputs with no extractable basename (empty
126///   string, bare path separator, trailing slash).
127///
128/// Used both by [`trigger_workflow`] to build the real dispatch URL
129/// and by the TUI's Trigger-tab curl preview via this crate's public
130/// API, so the preview and the actual POST land on the same endpoint.
131pub fn workflow_dispatch_path_segment(name: &str) -> Option<String> {
132    let basename = name.rsplit(['/', '\\']).next()?;
133    if basename.is_empty() {
134        return None;
135    }
136    if basename.ends_with(".yml") || basename.ends_with(".yaml") {
137        Some(basename.to_string())
138    } else {
139        Some(format!("{basename}.yml"))
140    }
141}
142
143/// Get the list of available workflows in the repository
144pub async fn list_workflows(_repo_info: &RepoInfo) -> Result<Vec<String>, GithubError> {
145    let workflows_dir = Path::new(".github/workflows");
146
147    if !workflows_dir.exists() {
148        return Err(GithubError::IoError(std::io::Error::new(
149            std::io::ErrorKind::NotFound,
150            "Workflows directory not found",
151        )));
152    }
153
154    let mut workflow_names = Vec::new();
155
156    for entry in fs::read_dir(workflows_dir)? {
157        let entry = entry?;
158        let path = entry.path();
159
160        if path.is_file()
161            && path
162                .extension()
163                .is_some_and(|ext| ext == "yml" || ext == "yaml")
164        {
165            if let Some(file_name) = path.file_stem() {
166                if let Some(name) = file_name.to_str() {
167                    workflow_names.push(name.to_string());
168                }
169            }
170        }
171    }
172
173    Ok(workflow_names)
174}
175
176/// Trigger a workflow on GitHub
177pub async fn trigger_workflow(
178    workflow_name: &str,
179    branch: Option<&str>,
180    inputs: Option<HashMap<String, String>>,
181) -> Result<(), GithubError> {
182    // Get GitHub token from environment
183    let token = std::env::var("GITHUB_TOKEN").map_err(|_| GithubError::TokenNotFound)?;
184
185    // Trim the token to remove any leading or trailing whitespace
186    let trimmed_token = token.trim();
187
188    // Convert token to HeaderValue
189    let token_header = header::HeaderValue::from_str(&format!("Bearer {}", trimmed_token))
190        .map_err(|_| GithubError::GitParseError("Invalid token format".to_string()))?;
191
192    // Get repository information
193    let repo_info = get_repo_info()?;
194    wrkflw_logging::info(&format!(
195        "Repository: {}/{}",
196        repo_info.owner, repo_info.repo
197    ));
198
199    // Prepare the request payload
200    let branch_ref = branch.unwrap_or(&repo_info.default_branch);
201    wrkflw_logging::info(&format!("Using branch: {}", branch_ref));
202
203    // Normalize the user-facing identifier into the dispatch URL
204    // segment. Handles subdir prefixes (drop) and missing extensions
205    // (append `.yml`) so `"ci"`, `"ci.yml"`, `"ci.yaml"`, and
206    // `"release/prod.yml"` all produce the same URL shape the REST
207    // API expects. The TUI preview goes through the same helper so
208    // a copy-pasted curl lands on the same endpoint.
209    let workflow_segment = workflow_dispatch_path_segment(workflow_name)
210        .ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))?;
211
212    wrkflw_logging::info(&format!("Using workflow file: {}", workflow_segment));
213
214    // Create simplified payload
215    let mut payload = serde_json::json!({
216        "ref": branch_ref
217    });
218
219    // Add inputs if provided
220    if let Some(input_map) = inputs {
221        payload["inputs"] = serde_json::json!(input_map);
222        wrkflw_logging::info(&format!("With inputs: {:?}", input_map));
223    }
224
225    // Send the workflow_dispatch event
226    let url = format!(
227        "https://api.github.com/repos/{}/{}/actions/workflows/{}/dispatches",
228        repo_info.owner, repo_info.repo, workflow_segment
229    );
230
231    wrkflw_logging::info(&format!("Triggering workflow at URL: {}", url));
232
233    // Create a reqwest client
234    let client = reqwest::Client::new();
235
236    // Send the request using reqwest
237    let response = client
238        .post(&url)
239        .header(header::AUTHORIZATION, token_header)
240        .header(header::ACCEPT, "application/vnd.github.v3+json")
241        .header(header::CONTENT_TYPE, "application/json")
242        .header(header::USER_AGENT, "wrkflw-cli")
243        .json(&payload)
244        .send()
245        .await
246        .map_err(GithubError::RequestError)?;
247
248    if !response.status().is_success() {
249        let status = response.status().as_u16();
250        let error_message = response
251            .text()
252            .await
253            .unwrap_or_else(|_| format!("Unknown error (HTTP {})", status));
254
255        // Add more detailed error information
256        let error_details = if status == 500 {
257            "Internal server error from GitHub. This could be due to:\n\
258             1. The workflow file doesn't exist in the repository\n\
259             2. The GitHub token doesn't have sufficient permissions\n\
260             3. There's an issue with the workflow file itself\n\
261             Please check:\n\
262             - The workflow file exists at .github/workflows/rust.yml\n\
263             - Your GitHub token has the 'workflow' scope\n\
264             - The workflow file is valid YAML"
265        } else {
266            &error_message
267        };
268
269        return Err(GithubError::ApiError {
270            status,
271            message: error_details.to_string(),
272        });
273    }
274
275    wrkflw_logging::info("Workflow triggered successfully!");
276    wrkflw_logging::info(&format!(
277        "View runs at: https://github.com/{}/{}/actions/workflows/{}",
278        repo_info.owner, repo_info.repo, workflow_segment
279    ));
280
281    // Attempt to verify the workflow was actually triggered
282    match list_recent_workflow_runs(&repo_info, &workflow_segment, &token).await {
283        Ok(runs) => {
284            if !runs.is_empty() {
285                wrkflw_logging::info("Recent runs of this workflow:");
286                for run in runs.iter().take(3) {
287                    wrkflw_logging::info(&format!(
288                        "- Run #{} ({}): {}",
289                        run.get("id").and_then(|id| id.as_u64()).unwrap_or(0),
290                        run.get("status")
291                            .and_then(|s| s.as_str())
292                            .unwrap_or("unknown"),
293                        run.get("html_url").and_then(|u| u.as_str()).unwrap_or("")
294                    ));
295                }
296            } else {
297                wrkflw_logging::info(
298                    "No recent runs found. The workflow might still be initializing.",
299                );
300                wrkflw_logging::info(&format!(
301                    "Check GitHub UI in a few moments: https://github.com/{}/{}/actions",
302                    repo_info.owner, repo_info.repo
303                ));
304            }
305        }
306        Err(e) => {
307            wrkflw_logging::warning(&format!("Could not fetch recent workflow runs: {}", e));
308            wrkflw_logging::info(&format!(
309                "This doesn't mean the trigger failed - check GitHub UI: https://github.com/{}/{}/actions",
310                repo_info.owner, repo_info.repo
311            ));
312        }
313    }
314
315    Ok(())
316}
317
318/// List recent workflow runs for a specific workflow. `workflow_segment`
319/// must already be the basename-form produced by
320/// [`workflow_dispatch_path_segment`] (e.g. `"ci.yml"`), not the raw
321/// user-facing identifier — the caller has already normalized it.
322async fn list_recent_workflow_runs(
323    repo_info: &RepoInfo,
324    workflow_segment: &str,
325    token: &str,
326) -> Result<Vec<serde_json::Value>, GithubError> {
327    // Get recent workflow runs via GitHub API
328    let url = format!(
329        "https://api.github.com/repos/{}/{}/actions/workflows/{}/runs?per_page=5",
330        repo_info.owner, repo_info.repo, workflow_segment
331    );
332
333    let client = reqwest::Client::new();
334    let response = client
335        .get(&url)
336        .header(header::AUTHORIZATION, format!("Bearer {}", token))
337        .header(header::ACCEPT, "application/vnd.github.v3+json")
338        .header(header::USER_AGENT, "wrkflw-cli")
339        .send()
340        .await
341        .map_err(GithubError::RequestError)?;
342
343    if !response.status().is_success() {
344        let status = response.status().as_u16();
345        let error_message = response
346            .text()
347            .await
348            .unwrap_or_else(|_| format!("Unknown error (HTTP {})", status));
349        return Err(GithubError::ApiError {
350            status,
351            message: error_message,
352        });
353    }
354
355    let parsed: serde_json::Value = response
356        .json()
357        .await
358        .map_err(|e| GithubError::GitParseError(format!("Failed to parse workflow runs: {}", e)))?;
359
360    // Extract the workflow runs from the response
361    if let Some(workflow_runs) = parsed.get("workflow_runs").and_then(|wr| wr.as_array()) {
362        Ok(workflow_runs.clone())
363    } else {
364        Ok(Vec::new())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn workflow_dispatch_path_segment_keeps_yml_extension() {
374        assert_eq!(
375            workflow_dispatch_path_segment("ci.yml"),
376            Some("ci.yml".into())
377        );
378    }
379
380    #[test]
381    fn workflow_dispatch_path_segment_keeps_yaml_extension() {
382        // Regression: the old dispatcher unconditionally appended
383        // `.yml`, turning `ci.yaml` into `ci.yaml.yml` which was a
384        // guaranteed 404. The helper must round-trip the `.yaml`
385        // form untouched.
386        assert_eq!(
387            workflow_dispatch_path_segment("ci.yaml"),
388            Some("ci.yaml".into())
389        );
390    }
391
392    #[test]
393    fn workflow_dispatch_path_segment_appends_yml_when_missing() {
394        assert_eq!(workflow_dispatch_path_segment("ci"), Some("ci.yml".into()));
395    }
396
397    #[test]
398    fn workflow_dispatch_path_segment_strips_subdir_prefix() {
399        // GitHub does not support subdirs under `.github/workflows/`,
400        // but the caller may pass a filesystem-like path. The helper
401        // drops everything before the final path separator so the
402        // segment always addresses the workflow by basename.
403        assert_eq!(
404            workflow_dispatch_path_segment("release/prod.yml"),
405            Some("prod.yml".into())
406        );
407        assert_eq!(
408            workflow_dispatch_path_segment("deep/nested/ci.yaml"),
409            Some("ci.yaml".into())
410        );
411    }
412
413    #[test]
414    fn workflow_dispatch_path_segment_rejects_inputs_with_no_basename() {
415        assert_eq!(workflow_dispatch_path_segment(""), None);
416        assert_eq!(workflow_dispatch_path_segment("/"), None);
417        assert_eq!(workflow_dispatch_path_segment("foo/"), None);
418    }
419
420    #[test]
421    fn workflow_dispatch_path_segment_matches_across_dispatcher_and_preview() {
422        // The whole point of the helper is that the preview and the
423        // real dispatcher produce the same URL segment for the same
424        // input. Pin the identities the Trigger-tab curl preview
425        // depends on so a refactor in either place can't silently
426        // drift them apart.
427        for input in [
428            "ci",
429            "ci.yml",
430            "ci.yaml",
431            "release/prod.yml",
432            "deep/nested/ci.yaml",
433            "has spaces.yml",
434            "weird;name.yml",
435        ] {
436            let segment = workflow_dispatch_path_segment(input)
437                .unwrap_or_else(|| panic!("helper produced None for {:?}", input));
438            assert!(
439                !segment.contains('/') && !segment.contains('\\'),
440                "segment {:?} for input {:?} must be a bare basename",
441                segment,
442                input
443            );
444            assert!(
445                segment.ends_with(".yml") || segment.ends_with(".yaml"),
446                "segment {:?} for input {:?} must carry an extension",
447                segment,
448                input
449            );
450        }
451    }
452}