1use 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#[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 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
336pub fn upload_log(log_path: &str) -> Option<String> {
338 upload_log_with_options(log_path, false)
339}
340
341pub 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 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
377pub 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
394pub 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 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}