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    let which_cmd = if cfg!(windows) { "where" } else { "which" };
249    Command::new(which_cmd)
250        .arg("gh-upload-log")
251        .stdout(std::process::Stdio::null())
252        .stderr(std::process::Stdio::null())
253        .status()
254        .map(|s| s.success())
255        .unwrap_or(false)
256}
257
258/// Upload log file using gh-upload-log
259pub fn upload_log(log_path: &str) -> Option<String> {
260    let output = Command::new("gh-upload-log")
261        .args([log_path, "--public"])
262        .output()
263        .ok()?;
264
265    if !output.status.success() {
266        let stderr = String::from_utf8_lossy(&output.stderr);
267        println!("Warning: Log upload failed - {}", stderr);
268        return None;
269    }
270
271    let result = String::from_utf8_lossy(&output.stdout);
272
273    // Extract URL from output
274    let gist_re = regex::Regex::new(r"https://gist\.github\.com/[^\s]+").ok()?;
275    if let Some(m) = gist_re.find(&result) {
276        return Some(m.as_str().to_string());
277    }
278
279    let repo_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
280    if let Some(m) = repo_re.find(&result) {
281        return Some(m.as_str().to_string());
282    }
283
284    None
285}
286
287/// Check if we can create an issue in a repository
288pub fn can_create_issue(owner: &str, repo: &str) -> bool {
289    Command::new("gh")
290        .args([
291            "repo",
292            "view",
293            &format!("{}/{}", owner, repo),
294            "--json",
295            "name",
296        ])
297        .stdout(std::process::Stdio::null())
298        .stderr(std::process::Stdio::null())
299        .status()
300        .map(|s| s.success())
301        .unwrap_or(false)
302}
303
304/// Create an issue in the repository
305pub fn create_issue(
306    repo_info: &RepoInfo,
307    full_command: &str,
308    exit_code: i32,
309    log_url: Option<&str>,
310) -> Option<String> {
311    let title = format!(
312        "Command failed with exit code {}: {}{}",
313        exit_code,
314        &full_command[..50.min(full_command.len())],
315        if full_command.len() > 50 { "..." } else { "" }
316    );
317
318    let runtime = "Rust";
319    let runtime_version = env!("CARGO_PKG_VERSION");
320
321    let mut body = String::from("## Command Execution Failure Report\n\n");
322    body.push_str(&format!("**Command:** `{}`\n\n", full_command));
323    body.push_str(&format!("**Exit Code:** {}\n\n", exit_code));
324    body.push_str(&format!("**Timestamp:** {}\n\n", get_timestamp()));
325    body.push_str("### System Information\n\n");
326    body.push_str(&format!("- **Platform:** {}\n", std::env::consts::OS));
327    body.push_str(&format!("- **{} Version:** {}\n", runtime, runtime_version));
328    body.push_str(&format!(
329        "- **Architecture:** {}\n\n",
330        std::env::consts::ARCH
331    ));
332
333    if let Some(url) = log_url {
334        body.push_str("### Log File\n\n");
335        body.push_str(&format!("Full log available at: {}\n\n", url));
336    }
337
338    body.push_str("---\n");
339    body.push_str("*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n");
340
341    // Escape quotes in title and body for shell
342    let title_escaped = title.replace('"', "\\\"");
343    let body_escaped = body.replace('"', "\\\"").replace('\n', "\\n");
344
345    let output = Command::new("gh")
346        .args([
347            "issue",
348            "create",
349            "--repo",
350            &format!("{}/{}", repo_info.owner, repo_info.repo),
351            "--title",
352            &title_escaped,
353            "--body",
354            &body_escaped,
355        ])
356        .output()
357        .ok()?;
358
359    if !output.status.success() {
360        let stderr = String::from_utf8_lossy(&output.stderr);
361        println!("Warning: Issue creation failed - {}", stderr);
362        return None;
363    }
364
365    let result = String::from_utf8_lossy(&output.stdout);
366    let url_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
367    url_re.find(&result).map(|m| m.as_str().to_string())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_parse_git_url_https() {
376        let info = parse_git_url("https://github.com/owner/repo").unwrap();
377        assert_eq!(info.owner, "owner");
378        assert_eq!(info.repo, "repo");
379        assert_eq!(info.url, "https://github.com/owner/repo");
380    }
381
382    #[test]
383    fn test_parse_git_url_ssh() {
384        let info = parse_git_url("git@github.com:owner/repo.git").unwrap();
385        assert_eq!(info.owner, "owner");
386        assert_eq!(info.repo, "repo");
387    }
388
389    #[test]
390    fn test_parse_git_url_with_git_suffix() {
391        let info = parse_git_url("https://github.com/owner/repo.git").unwrap();
392        assert_eq!(info.repo, "repo");
393    }
394
395    #[test]
396    fn test_parse_git_url_invalid() {
397        assert!(parse_git_url("").is_none());
398        assert!(parse_git_url("not a url").is_none());
399    }
400}