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