1use 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#[derive(Debug, Default)]
14pub struct Config {
15 pub disable_auto_issue: bool,
17 pub disable_log_upload: bool,
19 pub verbose: bool,
21}
22
23#[derive(Debug, Clone)]
25pub struct RepoInfo {
26 pub owner: String,
28 pub repo: String,
30 pub url: String,
32}
33
34pub 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 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 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 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 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 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 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
98pub fn detect_repository(cmd_name: &str) -> Option<RepoInfo> {
100 let is_windows = cfg!(windows);
101
102 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 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 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 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 if real_path_str.contains("node_modules") {
144 is_npm_package = true;
145 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 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 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 !is_npm_package {
169 return None;
170 }
171
172 let package_name = package_name.unwrap_or_else(|| cmd_name.to_string());
174
175 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 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 if let Some(info) = parse_git_url(&bugs_url) {
192 return Some(info);
193 }
194 }
195 }
196
197 None
198}
199
200fn 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
216pub 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
236pub 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
247pub 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
263pub 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
301pub fn upload_log_interactive(log_path: &str) -> Result<i32, String> {
303 upload_log_interactive_with_options(log_path, false, false)
304}
305
306pub 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
335pub fn upload_log(log_path: &str) -> Option<String> {
337 upload_log_with_options(log_path, false)
338}
339
340pub 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 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
376pub 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
393pub 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 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}