start_command/
failure_handler.rs1use 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 let which_cmd = if cfg!(windows) { "where" } else { "which" };
249 Command::new(which_cmd)
250 .arg("gh-upload-log")
251 .stdout(std::process::Stdio::null())
252 .stderr(std::process::Stdio::null())
253 .status()
254 .map(|s| s.success())
255 .unwrap_or(false)
256}
257
258pub fn upload_log(log_path: &str) -> Option<String> {
260 let output = Command::new("gh-upload-log")
261 .args([log_path, "--public"])
262 .output()
263 .ok()?;
264
265 if !output.status.success() {
266 let stderr = String::from_utf8_lossy(&output.stderr);
267 println!("Warning: Log upload failed - {}", stderr);
268 return None;
269 }
270
271 let result = String::from_utf8_lossy(&output.stdout);
272
273 let gist_re = regex::Regex::new(r"https://gist\.github\.com/[^\s]+").ok()?;
275 if let Some(m) = gist_re.find(&result) {
276 return Some(m.as_str().to_string());
277 }
278
279 let repo_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
280 if let Some(m) = repo_re.find(&result) {
281 return Some(m.as_str().to_string());
282 }
283
284 None
285}
286
287pub fn can_create_issue(owner: &str, repo: &str) -> bool {
289 Command::new("gh")
290 .args([
291 "repo",
292 "view",
293 &format!("{}/{}", owner, repo),
294 "--json",
295 "name",
296 ])
297 .stdout(std::process::Stdio::null())
298 .stderr(std::process::Stdio::null())
299 .status()
300 .map(|s| s.success())
301 .unwrap_or(false)
302}
303
304pub fn create_issue(
306 repo_info: &RepoInfo,
307 full_command: &str,
308 exit_code: i32,
309 log_url: Option<&str>,
310) -> Option<String> {
311 let title = format!(
312 "Command failed with exit code {}: {}{}",
313 exit_code,
314 &full_command[..50.min(full_command.len())],
315 if full_command.len() > 50 { "..." } else { "" }
316 );
317
318 let runtime = "Rust";
319 let runtime_version = env!("CARGO_PKG_VERSION");
320
321 let mut body = String::from("## Command Execution Failure Report\n\n");
322 body.push_str(&format!("**Command:** `{}`\n\n", full_command));
323 body.push_str(&format!("**Exit Code:** {}\n\n", exit_code));
324 body.push_str(&format!("**Timestamp:** {}\n\n", get_timestamp()));
325 body.push_str("### System Information\n\n");
326 body.push_str(&format!("- **Platform:** {}\n", std::env::consts::OS));
327 body.push_str(&format!("- **{} Version:** {}\n", runtime, runtime_version));
328 body.push_str(&format!(
329 "- **Architecture:** {}\n\n",
330 std::env::consts::ARCH
331 ));
332
333 if let Some(url) = log_url {
334 body.push_str("### Log File\n\n");
335 body.push_str(&format!("Full log available at: {}\n\n", url));
336 }
337
338 body.push_str("---\n");
339 body.push_str("*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n");
340
341 let title_escaped = title.replace('"', "\\\"");
343 let body_escaped = body.replace('"', "\\\"").replace('\n', "\\n");
344
345 let output = Command::new("gh")
346 .args([
347 "issue",
348 "create",
349 "--repo",
350 &format!("{}/{}", repo_info.owner, repo_info.repo),
351 "--title",
352 &title_escaped,
353 "--body",
354 &body_escaped,
355 ])
356 .output()
357 .ok()?;
358
359 if !output.status.success() {
360 let stderr = String::from_utf8_lossy(&output.stderr);
361 println!("Warning: Issue creation failed - {}", stderr);
362 return None;
363 }
364
365 let result = String::from_utf8_lossy(&output.stdout);
366 let url_re = regex::Regex::new(r"https://github\.com/[^\s]+").ok()?;
367 url_re.find(&result).map(|m| m.as_str().to_string())
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn test_parse_git_url_https() {
376 let info = parse_git_url("https://github.com/owner/repo").unwrap();
377 assert_eq!(info.owner, "owner");
378 assert_eq!(info.repo, "repo");
379 assert_eq!(info.url, "https://github.com/owner/repo");
380 }
381
382 #[test]
383 fn test_parse_git_url_ssh() {
384 let info = parse_git_url("git@github.com:owner/repo.git").unwrap();
385 assert_eq!(info.owner, "owner");
386 assert_eq!(info.repo, "repo");
387 }
388
389 #[test]
390 fn test_parse_git_url_with_git_suffix() {
391 let info = parse_git_url("https://github.com/owner/repo.git").unwrap();
392 assert_eq!(info.repo, "repo");
393 }
394
395 #[test]
396 fn test_parse_git_url_invalid() {
397 assert!(parse_git_url("").is_none());
398 assert!(parse_git_url("not a url").is_none());
399 }
400}