Skip to main content

start_command/
failure_handler.rs

1//! Failure handler for start-command
2//!
3//! Handles command failures - detects repository, uploads logs, creates issues
4
5use std::env;
6use std::fs;
7use std::process::Command;
8
9use crate::isolation::get_timestamp;
10
11/// Configuration for the failure handler
12#[derive(Debug, Default)]
13pub struct Config {
14    /// Disable automatic issue creation
15    pub disable_auto_issue: bool,
16    /// Disable log upload
17    pub disable_log_upload: bool,
18    /// Verbose mode
19    pub verbose: bool,
20}
21
22/// Repository information
23#[derive(Debug, Clone)]
24pub struct RepoInfo {
25    /// Repository owner
26    pub owner: String,
27    /// Repository name
28    pub repo: String,
29    /// Full URL
30    pub url: String,
31}
32
33/// Handle command failure - detect repository, upload log, create issue
34pub fn handle_failure(
35    config: &Config,
36    cmd_name: &str,
37    full_command: &str,
38    exit_code: i32,
39    log_path: &str,
40) {
41    println!();
42
43    // Check if auto-issue is disabled
44    if config.disable_auto_issue {
45        if config.verbose {
46            println!("Auto-issue creation disabled via START_DISABLE_AUTO_ISSUE");
47        }
48        return;
49    }
50
51    // Try to detect repository for the command
52    let repo_info = match detect_repository(cmd_name) {
53        Some(info) => info,
54        None => {
55            println!("Repository not detected - automatic issue creation skipped");
56            return;
57        }
58    };
59
60    println!("Detected repository: {}", repo_info.url);
61
62    // Check if gh CLI is available and authenticated
63    if !is_gh_authenticated() {
64        println!("GitHub CLI not authenticated - automatic issue creation skipped");
65        println!("Run \"gh auth login\" to enable automatic issue creation");
66        return;
67    }
68
69    // Try to upload log
70    let mut log_url = None;
71    if config.disable_log_upload {
72        if config.verbose {
73            println!("Log upload disabled via START_DISABLE_LOG_UPLOAD");
74        }
75    } else if is_gh_upload_log_available() {
76        log_url = upload_log(log_path);
77        if let Some(ref url) = log_url {
78            println!("Log uploaded: {}", url);
79        }
80    } else {
81        println!("gh-upload-log not installed - log upload skipped");
82        println!("Install with: bun install -g gh-upload-log");
83    }
84
85    // Check if we can create issues in this repository
86    if !can_create_issue(&repo_info.owner, &repo_info.repo) {
87        println!("Cannot create issue in repository - skipping issue creation");
88        return;
89    }
90
91    // Create issue
92    if let Some(issue_url) = create_issue(&repo_info, full_command, exit_code, log_url.as_deref()) {
93        println!("Issue created: {}", issue_url);
94    }
95}
96
97/// Detect repository URL for a command (currently supports NPM global packages)
98pub fn detect_repository(cmd_name: &str) -> Option<RepoInfo> {
99    let is_windows = cfg!(windows);
100
101    // Find command location
102    let which_cmd = if is_windows { "where" } else { "which" };
103    let cmd_path = Command::new(which_cmd)
104        .arg(cmd_name)
105        .output()
106        .ok()
107        .filter(|o| o.status.success())
108        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
109
110    if cmd_path.is_empty() {
111        return None;
112    }
113
114    // Handle Windows where command that returns multiple lines
115    let cmd_path = if is_windows {
116        cmd_path
117            .lines()
118            .next()
119            .unwrap_or(&cmd_path)
120            .trim()
121            .to_string()
122    } else {
123        cmd_path
124    };
125
126    // Check if it's in npm global modules
127    let _npm_global_path = Command::new("npm")
128        .args(["root", "-g"])
129        .output()
130        .ok()
131        .filter(|o| o.status.success())
132        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
133
134    // Check if the command is related to npm
135    let real_path = fs::canonicalize(&cmd_path).ok()?;
136    let real_path_str = real_path.to_string_lossy();
137
138    let mut package_name = None;
139    let mut is_npm_package = false;
140
141    // Check if the real path is within node_modules
142    if real_path_str.contains("node_modules") {
143        is_npm_package = true;
144        // Extract package name from path
145        if let Some(idx) = real_path_str.find("node_modules/") {
146            let after = &real_path_str[idx + 13..];
147            package_name = after.split('/').next().map(String::from);
148        }
149    }
150
151    // Try to read the bin script to extract package info
152    if package_name.is_none() {
153        if let Ok(content) = fs::read_to_string(&cmd_path) {
154            if content.starts_with("#!/usr/bin/env node") || content.contains("node_modules") {
155                is_npm_package = true;
156
157                // Look for package path in the script
158                let re = regex::Regex::new(r#"node_modules/([^/'"]+)"#).ok()?;
159                if let Some(cap) = re.captures(&content) {
160                    package_name = cap.get(1).map(|m| m.as_str().to_string());
161                }
162            }
163        }
164    }
165
166    // If we couldn't confirm this is an npm package, don't proceed
167    if !is_npm_package {
168        return None;
169    }
170
171    // Use command name if package name not found
172    let package_name = package_name.unwrap_or_else(|| cmd_name.to_string());
173
174    // Try to get repository URL from npm
175    if let Some(npm_url) = get_npm_repository_url(&package_name) {
176        if let Some(info) = parse_git_url(&npm_url) {
177            return Some(info);
178        }
179    }
180
181    // Try to get bugs URL as fallback
182    if let Ok(output) = Command::new("npm")
183        .args(["view", &package_name, "bugs.url"])
184        .output()
185    {
186        if output.status.success() {
187            let bugs_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
188            if bugs_url.contains("github.com") {
189                if let Some(info) = parse_git_url(&bugs_url) {
190                    return Some(info);
191                }
192            }
193        }
194    }
195
196    None
197}
198
199/// Get repository URL from npm registry
200fn get_npm_repository_url(package_name: &str) -> Option<String> {
201    let output = Command::new("npm")
202        .args(["view", package_name, "repository.url"])
203        .output()
204        .ok()?;
205
206    if output.status.success() {
207        let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
208        if !url.is_empty() {
209            return Some(url);
210        }
211    }
212    None
213}
214
215/// Parse a git URL to extract owner, repo, and normalized URL
216pub fn parse_git_url(url: &str) -> Option<RepoInfo> {
217    if url.is_empty() {
218        return None;
219    }
220
221    let re = regex::Regex::new(r#"github\.com[/:]([^/]+)/([^/.]+)"#).ok()?;
222    let caps = re.captures(url)?;
223
224    let owner = caps.get(1)?.as_str().to_string();
225    let mut repo = caps.get(2)?.as_str().to_string();
226    repo = repo.trim_end_matches(".git").to_string();
227
228    Some(RepoInfo {
229        owner: owner.clone(),
230        repo: repo.clone(),
231        url: format!("https://github.com/{}/{}", owner, repo),
232    })
233}
234
235/// Check if GitHub CLI is authenticated
236pub fn is_gh_authenticated() -> bool {
237    Command::new("gh")
238        .args(["auth", "status"])
239        .stdout(std::process::Stdio::null())
240        .stderr(std::process::Stdio::null())
241        .status()
242        .map(|s| s.success())
243        .unwrap_or(false)
244}
245
246/// Check if gh-upload-log is available
247pub fn is_gh_upload_log_available() -> bool {
248    is_command_available("gh-upload-log")
249}
250
251fn is_command_available(command: &str) -> bool {
252    let which_cmd = if cfg!(windows) { "where" } else { "which" };
253    Command::new(which_cmd)
254        .arg(command)
255        .stdout(std::process::Stdio::null())
256        .stderr(std::process::Stdio::null())
257        .status()
258        .map(|s| s.success())
259        .unwrap_or(false)
260}
261
262/// Install gh-upload-log with an available JavaScript package manager.
263pub fn ensure_gh_upload_log_available() -> Result<(), String> {
264    if is_gh_upload_log_available() {
265        return Ok(());
266    }
267
268    let installers: [(&str, &[&str]); 2] = [
269        ("bun", &["install", "-g", "gh-upload-log"]),
270        ("npm", &["install", "-g", "gh-upload-log"]),
271    ];
272
273    for (command, args) in installers {
274        if !is_command_available(command) {
275            continue;
276        }
277
278        println!(
279            "gh-upload-log not found; installing with: {} {}",
280            command,
281            args.join(" ")
282        );
283        let installed = Command::new(command)
284            .args(args)
285            .status()
286            .map(|status| status.success())
287            .unwrap_or(false);
288
289        if installed && is_gh_upload_log_available() {
290            return Ok(());
291        }
292    }
293
294    Err(
295        "gh-upload-log is not installed and automatic installation did not make it available on PATH."
296            .to_string(),
297    )
298}
299
300/// Upload a log file with gh-upload-log and stream its output to the terminal.
301pub fn upload_log_interactive(log_path: &str) -> Result<i32, String> {
302    ensure_gh_upload_log_available()?;
303
304    let status = Command::new("gh-upload-log")
305        .arg(log_path)
306        .status()
307        .map_err(|e| format!("Failed to run gh-upload-log: {}", e))?;
308
309    Ok(status.code().unwrap_or(1))
310}
311
312/// Upload log file using gh-upload-log
313pub fn upload_log(log_path: &str) -> Option<String> {
314    let output = Command::new("gh-upload-log")
315        .args([log_path, "--public"])
316        .output()
317        .ok()?;
318
319    if !output.status.success() {
320        let stderr = String::from_utf8_lossy(&output.stderr);
321        println!("Warning: Log upload failed - {}", stderr);
322        return None;
323    }
324
325    let result = String::from_utf8_lossy(&output.stdout);
326
327    // Extract URL from output
328    let gist_re = regex::Regex::new(r"https://gist\.github\.com/[^\s]+").ok()?;
329    if let Some(m) = gist_re.find(&result) {
330        return Some(m.as_str().to_string());
331    }
332
333    let repo_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
334    if let Some(m) = repo_re.find(&result) {
335        return Some(m.as_str().to_string());
336    }
337
338    None
339}
340
341/// Check if we can create an issue in a repository
342pub fn can_create_issue(owner: &str, repo: &str) -> bool {
343    Command::new("gh")
344        .args([
345            "repo",
346            "view",
347            &format!("{}/{}", owner, repo),
348            "--json",
349            "name",
350        ])
351        .stdout(std::process::Stdio::null())
352        .stderr(std::process::Stdio::null())
353        .status()
354        .map(|s| s.success())
355        .unwrap_or(false)
356}
357
358/// Create an issue in the repository
359pub fn create_issue(
360    repo_info: &RepoInfo,
361    full_command: &str,
362    exit_code: i32,
363    log_url: Option<&str>,
364) -> Option<String> {
365    let title = format!(
366        "Command failed with exit code {}: {}{}",
367        exit_code,
368        &full_command[..50.min(full_command.len())],
369        if full_command.len() > 50 { "..." } else { "" }
370    );
371
372    let runtime = "Rust";
373    let runtime_version = env!("CARGO_PKG_VERSION");
374
375    let mut body = String::from("## Command Execution Failure Report\n\n");
376    body.push_str(&format!("**Command:** `{}`\n\n", full_command));
377    body.push_str(&format!("**Exit Code:** {}\n\n", exit_code));
378    body.push_str(&format!("**Timestamp:** {}\n\n", get_timestamp()));
379    body.push_str("### System Information\n\n");
380    body.push_str(&format!("- **Platform:** {}\n", std::env::consts::OS));
381    body.push_str(&format!("- **{} Version:** {}\n", runtime, runtime_version));
382    body.push_str(&format!(
383        "- **Architecture:** {}\n\n",
384        std::env::consts::ARCH
385    ));
386
387    if let Some(url) = log_url {
388        body.push_str("### Log File\n\n");
389        body.push_str(&format!("Full log available at: {}\n\n", url));
390    }
391
392    body.push_str("---\n");
393    body.push_str("*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n");
394
395    // Escape quotes in title and body for shell
396    let title_escaped = title.replace('"', "\\\"");
397    let body_escaped = body.replace('"', "\\\"").replace('\n', "\\n");
398
399    let output = Command::new("gh")
400        .args([
401            "issue",
402            "create",
403            "--repo",
404            &format!("{}/{}", repo_info.owner, repo_info.repo),
405            "--title",
406            &title_escaped,
407            "--body",
408            &body_escaped,
409        ])
410        .output()
411        .ok()?;
412
413    if !output.status.success() {
414        let stderr = String::from_utf8_lossy(&output.stderr);
415        println!("Warning: Issue creation failed - {}", stderr);
416        return None;
417    }
418
419    let result = String::from_utf8_lossy(&output.stdout);
420    let url_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
421    url_re.find(&result).map(|m| m.as_str().to_string())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn test_parse_git_url_https() {
430        let info = parse_git_url("https://github.com/owner/repo").unwrap();
431        assert_eq!(info.owner, "owner");
432        assert_eq!(info.repo, "repo");
433        assert_eq!(info.url, "https://github.com/owner/repo");
434    }
435
436    #[test]
437    fn test_parse_git_url_ssh() {
438        let info = parse_git_url("git@github.com:owner/repo.git").unwrap();
439        assert_eq!(info.owner, "owner");
440        assert_eq!(info.repo, "repo");
441    }
442
443    #[test]
444    fn test_parse_git_url_with_git_suffix() {
445        let info = parse_git_url("https://github.com/owner/repo.git").unwrap();
446        assert_eq!(info.repo, "repo");
447    }
448
449    #[test]
450    fn test_parse_git_url_invalid() {
451        assert!(parse_git_url("").is_none());
452        assert!(parse_git_url("not a url").is_none());
453    }
454}