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 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 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
116pub 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
143pub 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
176pub async fn trigger_workflow(
178 workflow_name: &str,
179 branch: Option<&str>,
180 inputs: Option<HashMap<String, String>>,
181) -> Result<(), GithubError> {
182 let token = std::env::var("GITHUB_TOKEN").map_err(|_| GithubError::TokenNotFound)?;
184
185 let trimmed_token = token.trim();
187
188 let token_header = header::HeaderValue::from_str(&format!("Bearer {}", trimmed_token))
190 .map_err(|_| GithubError::GitParseError("Invalid token format".to_string()))?;
191
192 let repo_info = get_repo_info()?;
194 wrkflw_logging::info(&format!(
195 "Repository: {}/{}",
196 repo_info.owner, repo_info.repo
197 ));
198
199 let branch_ref = branch.unwrap_or(&repo_info.default_branch);
201 wrkflw_logging::info(&format!("Using branch: {}", branch_ref));
202
203 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 let mut payload = serde_json::json!({
216 "ref": branch_ref
217 });
218
219 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 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 let client = reqwest::Client::new();
235
236 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 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 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
318async fn list_recent_workflow_runs(
323 repo_info: &RepoInfo,
324 workflow_segment: &str,
325 token: &str,
326) -> Result<Vec<serde_json::Value>, GithubError> {
327 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 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 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 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 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}