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::{ensure_sanitizer_available, sanitize_log_to_temp, sanitize_text};
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        ensure_sanitizer_available()?;
316        Some(sanitize_log_to_temp(
317            std::path::Path::new(log_path),
318            verbose,
319        )?)
320    };
321    let upload_path = prepared
322        .as_ref()
323        .map(|copy| copy.path.as_path())
324        .unwrap_or_else(|| std::path::Path::new(log_path));
325    ensure_gh_upload_log_available()?;
326
327    let status = Command::new("gh-upload-log")
328        .arg(upload_path)
329        .arg("--private")
330        .status()
331        .map_err(|_| "Failed to run gh-upload-log.".to_string())?;
332
333    Ok(status.code().unwrap_or(1))
334}
335
336/// Upload log file using gh-upload-log
337pub fn upload_log(log_path: &str) -> Option<String> {
338    upload_log_with_options(log_path, false)
339}
340
341/// Automatic log publication always sanitizes; diagnostics contain counts only.
342pub fn upload_log_with_options(log_path: &str, verbose: bool) -> Option<String> {
343    let prepared = match sanitize_log_to_temp(std::path::Path::new(log_path), verbose) {
344        Ok(copy) => copy,
345        Err(message) => {
346            println!("Warning: {}", message);
347            return None;
348        }
349    };
350    let output = Command::new("gh-upload-log")
351        .arg(&prepared.path)
352        .arg("--private")
353        .output()
354        .ok()?;
355
356    if !output.status.success() {
357        println!("Warning: Log upload failed.");
358        return None;
359    }
360
361    let result = String::from_utf8_lossy(&output.stdout);
362
363    // Extract URL from output
364    let gist_re = regex::Regex::new(r"https://gist\.github\.com/[^\s]+").ok()?;
365    if let Some(m) = gist_re.find(&result) {
366        return Some(m.as_str().to_string());
367    }
368
369    let repo_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
370    if let Some(m) = repo_re.find(&result) {
371        return Some(m.as_str().to_string());
372    }
373
374    None
375}
376
377/// Check if we can create an issue in a repository
378pub fn can_create_issue(owner: &str, repo: &str) -> bool {
379    Command::new("gh")
380        .args([
381            "repo",
382            "view",
383            &format!("{}/{}", owner, repo),
384            "--json",
385            "name",
386        ])
387        .stdout(std::process::Stdio::null())
388        .stderr(std::process::Stdio::null())
389        .status()
390        .map(|s| s.success())
391        .unwrap_or(false)
392}
393
394/// Create an issue in the repository
395pub fn create_issue(
396    repo_info: &RepoInfo,
397    full_command: &str,
398    exit_code: i32,
399    log_url: Option<&str>,
400) -> Option<String> {
401    let safe_command = match sanitize_text(full_command) {
402        Ok(text) => text,
403        Err(message) => {
404            println!("Warning: Issue creation failed - {}", message);
405            return None;
406        }
407    };
408    let full_command = safe_command.as_str();
409    let title = format!(
410        "Command failed with exit code {}: {}{}",
411        exit_code,
412        full_command.chars().take(50).collect::<String>(),
413        if full_command.len() > 50 { "..." } else { "" }
414    );
415
416    let runtime = "Rust";
417    let runtime_version = env!("CARGO_PKG_VERSION");
418
419    let mut body = String::from("## Command Execution Failure Report\n\n");
420    body.push_str(&format!("**Command:** `{}`\n\n", full_command));
421    body.push_str(&format!("**Exit Code:** {}\n\n", exit_code));
422    body.push_str(&format!("**Timestamp:** {}\n\n", get_timestamp()));
423    body.push_str("### System Information\n\n");
424    body.push_str(&format!("- **Platform:** {}\n", std::env::consts::OS));
425    body.push_str(&format!("- **{} Version:** {}\n", runtime, runtime_version));
426    body.push_str(&format!(
427        "- **Architecture:** {}\n\n",
428        std::env::consts::ARCH
429    ));
430
431    if let Some(url) = log_url {
432        body.push_str("### Log File\n\n");
433        body.push_str(&format!("Full log available at: {}\n\n", url));
434    }
435
436    body.push_str("---\n");
437    body.push_str("*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n");
438
439    // No shell is involved: `Command` passes each argument through verbatim, so
440    // escaping quotes here would only put backslashes into the issue text and
441    // flatten its newlines into the two characters "\n" (issue #168).
442    let output = Command::new("gh")
443        .args([
444            "issue",
445            "create",
446            "--repo",
447            &format!("{}/{}", repo_info.owner, repo_info.repo),
448            "--title",
449            &title,
450            "--body",
451            &body,
452        ])
453        .output()
454        .ok()?;
455
456    if !output.status.success() {
457        let stderr = String::from_utf8_lossy(&output.stderr);
458        println!("Warning: Issue creation failed - {}", stderr);
459        return None;
460    }
461
462    let result = String::from_utf8_lossy(&output.stdout);
463    let url_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
464    url_re.find(&result).map(|m| m.as_str().to_string())
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_parse_git_url_https() {
473        let info = parse_git_url("https://github.com/owner/repo").unwrap();
474        assert_eq!(info.owner, "owner");
475        assert_eq!(info.repo, "repo");
476        assert_eq!(info.url, "https://github.com/owner/repo");
477    }
478
479    #[test]
480    fn test_parse_git_url_ssh() {
481        let info = parse_git_url("git@github.com:owner/repo.git").unwrap();
482        assert_eq!(info.owner, "owner");
483        assert_eq!(info.repo, "repo");
484    }
485
486    #[test]
487    fn test_parse_git_url_with_git_suffix() {
488        let info = parse_git_url("https://github.com/owner/repo.git").unwrap();
489        assert_eq!(info.repo, "repo");
490    }
491
492    #[test]
493    fn test_parse_git_url_invalid() {
494        assert!(parse_git_url("").is_none());
495        assert!(parse_git_url("not a url").is_none());
496    }
497}