1use std::env;
6use std::fs;
7use std::process::Command;
8
9use crate::isolation::get_timestamp;
10
11#[derive(Debug, Default)]
13pub struct Config {
14 pub disable_auto_issue: bool,
16 pub disable_log_upload: bool,
18 pub verbose: bool,
20}
21
22#[derive(Debug, Clone)]
24pub struct RepoInfo {
25 pub owner: String,
27 pub repo: String,
29 pub url: String,
31}
32
33pub 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 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 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 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 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 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 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
97pub fn detect_repository(cmd_name: &str) -> Option<RepoInfo> {
99 let is_windows = cfg!(windows);
100
101 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 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 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 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 if real_path_str.contains("node_modules") {
143 is_npm_package = true;
144 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 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 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 !is_npm_package {
168 return None;
169 }
170
171 let package_name = package_name.unwrap_or_else(|| cmd_name.to_string());
173
174 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 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
199fn 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
215pub 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
235pub 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
246pub fn is_gh_upload_log_available() -> bool {
248 is_command_available("gh-upload-log")
249}
250
251fn is_command_available(command: &str) -> bool {
252 let which_cmd = if cfg!(windows) { "where" } else { "which" };
253 Command::new(which_cmd)
254 .arg(command)
255 .stdout(std::process::Stdio::null())
256 .stderr(std::process::Stdio::null())
257 .status()
258 .map(|s| s.success())
259 .unwrap_or(false)
260}
261
262pub fn ensure_gh_upload_log_available() -> Result<(), String> {
264 if is_gh_upload_log_available() {
265 return Ok(());
266 }
267
268 let installers: [(&str, &[&str]); 2] = [
269 ("bun", &["install", "-g", "gh-upload-log"]),
270 ("npm", &["install", "-g", "gh-upload-log"]),
271 ];
272
273 for (command, args) in installers {
274 if !is_command_available(command) {
275 continue;
276 }
277
278 println!(
279 "gh-upload-log not found; installing with: {} {}",
280 command,
281 args.join(" ")
282 );
283 let installed = Command::new(command)
284 .args(args)
285 .status()
286 .map(|status| status.success())
287 .unwrap_or(false);
288
289 if installed && is_gh_upload_log_available() {
290 return Ok(());
291 }
292 }
293
294 Err(
295 "gh-upload-log is not installed and automatic installation did not make it available on PATH."
296 .to_string(),
297 )
298}
299
300pub fn upload_log_interactive(log_path: &str) -> Result<i32, String> {
302 ensure_gh_upload_log_available()?;
303
304 let status = Command::new("gh-upload-log")
305 .arg(log_path)
306 .status()
307 .map_err(|e| format!("Failed to run gh-upload-log: {}", e))?;
308
309 Ok(status.code().unwrap_or(1))
310}
311
312pub fn upload_log(log_path: &str) -> Option<String> {
314 let output = Command::new("gh-upload-log")
315 .args([log_path, "--public"])
316 .output()
317 .ok()?;
318
319 if !output.status.success() {
320 let stderr = String::from_utf8_lossy(&output.stderr);
321 println!("Warning: Log upload failed - {}", stderr);
322 return None;
323 }
324
325 let result = String::from_utf8_lossy(&output.stdout);
326
327 let gist_re = regex::Regex::new(r"https://gist\.github\.com/[^\s]+").ok()?;
329 if let Some(m) = gist_re.find(&result) {
330 return Some(m.as_str().to_string());
331 }
332
333 let repo_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
334 if let Some(m) = repo_re.find(&result) {
335 return Some(m.as_str().to_string());
336 }
337
338 None
339}
340
341pub fn can_create_issue(owner: &str, repo: &str) -> bool {
343 Command::new("gh")
344 .args([
345 "repo",
346 "view",
347 &format!("{}/{}", owner, repo),
348 "--json",
349 "name",
350 ])
351 .stdout(std::process::Stdio::null())
352 .stderr(std::process::Stdio::null())
353 .status()
354 .map(|s| s.success())
355 .unwrap_or(false)
356}
357
358pub fn create_issue(
360 repo_info: &RepoInfo,
361 full_command: &str,
362 exit_code: i32,
363 log_url: Option<&str>,
364) -> Option<String> {
365 let title = format!(
366 "Command failed with exit code {}: {}{}",
367 exit_code,
368 &full_command[..50.min(full_command.len())],
369 if full_command.len() > 50 { "..." } else { "" }
370 );
371
372 let runtime = "Rust";
373 let runtime_version = env!("CARGO_PKG_VERSION");
374
375 let mut body = String::from("## Command Execution Failure Report\n\n");
376 body.push_str(&format!("**Command:** `{}`\n\n", full_command));
377 body.push_str(&format!("**Exit Code:** {}\n\n", exit_code));
378 body.push_str(&format!("**Timestamp:** {}\n\n", get_timestamp()));
379 body.push_str("### System Information\n\n");
380 body.push_str(&format!("- **Platform:** {}\n", std::env::consts::OS));
381 body.push_str(&format!("- **{} Version:** {}\n", runtime, runtime_version));
382 body.push_str(&format!(
383 "- **Architecture:** {}\n\n",
384 std::env::consts::ARCH
385 ));
386
387 if let Some(url) = log_url {
388 body.push_str("### Log File\n\n");
389 body.push_str(&format!("Full log available at: {}\n\n", url));
390 }
391
392 body.push_str("---\n");
393 body.push_str("*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n");
394
395 let title_escaped = title.replace('"', "\\\"");
397 let body_escaped = body.replace('"', "\\\"").replace('\n', "\\n");
398
399 let output = Command::new("gh")
400 .args([
401 "issue",
402 "create",
403 "--repo",
404 &format!("{}/{}", repo_info.owner, repo_info.repo),
405 "--title",
406 &title_escaped,
407 "--body",
408 &body_escaped,
409 ])
410 .output()
411 .ok()?;
412
413 if !output.status.success() {
414 let stderr = String::from_utf8_lossy(&output.stderr);
415 println!("Warning: Issue creation failed - {}", stderr);
416 return None;
417 }
418
419 let result = String::from_utf8_lossy(&output.stdout);
420 let url_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
421 url_re.find(&result).map(|m| m.as_str().to_string())
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_parse_git_url_https() {
430 let info = parse_git_url("https://github.com/owner/repo").unwrap();
431 assert_eq!(info.owner, "owner");
432 assert_eq!(info.repo, "repo");
433 assert_eq!(info.url, "https://github.com/owner/repo");
434 }
435
436 #[test]
437 fn test_parse_git_url_ssh() {
438 let info = parse_git_url("git@github.com:owner/repo.git").unwrap();
439 assert_eq!(info.owner, "owner");
440 assert_eq!(info.repo, "repo");
441 }
442
443 #[test]
444 fn test_parse_git_url_with_git_suffix() {
445 let info = parse_git_url("https://github.com/owner/repo.git").unwrap();
446 assert_eq!(info.repo, "repo");
447 }
448
449 #[test]
450 fn test_parse_git_url_invalid() {
451 assert!(parse_git_url("").is_none());
452 assert!(parse_git_url("not a url").is_none());
453 }
454}