miyabi_github/
auth.rs

1//! GitHub authentication utilities
2//!
3//! Provides utilities for discovering and validating GitHub authentication tokens
4//! from multiple sources with fallback logic.
5
6use miyabi_types::error::{MiyabiError, Result};
7use std::fs;
8use std::process::Command;
9
10/// Discover GitHub token from multiple sources with fallback
11///
12/// Tries the following sources in order:
13/// 1. `GITHUB_TOKEN` environment variable
14/// 2. `gh auth token` command (GitHub CLI)
15/// 3. `~/.config/gh/hosts.yml` file
16///
17/// # Returns
18/// - `Ok(String)` with the token if found
19/// - `Err(MiyabiError::Auth)` with helpful setup instructions if not found
20///
21/// # Examples
22///
23/// ```no_run
24/// use miyabi_github::auth::discover_token;
25///
26/// # async fn example() -> miyabi_types::error::Result<()> {
27/// let token = discover_token()?;
28/// println!("Found token: {}", &token[..10]); // Print first 10 chars
29/// # Ok(())
30/// # }
31/// ```
32pub fn discover_token() -> Result<String> {
33    // Try 1: GITHUB_TOKEN environment variable
34    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
35        if !token.is_empty() && token.starts_with("ghp_") {
36            tracing::debug!("Found GitHub token from GITHUB_TOKEN environment variable");
37            return Ok(token);
38        }
39    }
40
41    // Try 2: gh CLI command
42    if let Ok(token) = get_token_from_gh_cli() {
43        tracing::debug!("Found GitHub token from gh CLI");
44        return Ok(token);
45    }
46
47    // Try 3: gh config file
48    if let Ok(token) = get_token_from_gh_config() {
49        tracing::debug!("Found GitHub token from gh config file");
50        return Ok(token);
51    }
52
53    // No token found - return detailed error with setup instructions
54    Err(MiyabiError::Auth(
55        "GitHub token not found. Please set up authentication:\n\n\
56         Option 1: Set environment variable\n\
57         \x20 export GITHUB_TOKEN=ghp_your_token_here\n\
58         \x20 # Add to ~/.zshrc or ~/.bashrc for persistence\n\n\
59         Option 2: Use GitHub CLI (recommended)\n\
60         \x20 gh auth login\n\
61         \x20 # Follow the interactive prompts\n\n\
62         Option 3: Create a Personal Access Token\n\
63         \x20 1. Go to https://github.com/settings/tokens\n\
64         \x20 2. Generate new token (classic) with 'repo' scope\n\
65         \x20 3. Set GITHUB_TOKEN environment variable\n\n\
66         For more help, see: https://docs.github.com/en/authentication"
67            .to_string(),
68    ))
69}
70
71/// Get token from gh CLI using `gh auth token` command
72fn get_token_from_gh_cli() -> Result<String> {
73    let output = Command::new("gh")
74        .args(["auth", "token"])
75        .output()
76        .map_err(|e| MiyabiError::Auth(format!("Failed to execute gh command: {}", e)))?;
77
78    if output.status.success() {
79        let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
80        if !token.is_empty() && token.starts_with("ghp_") {
81            return Ok(token);
82        }
83    }
84
85    Err(MiyabiError::Auth("gh CLI is available but not authenticated".to_string()))
86}
87
88/// Get token from gh config file (~/.config/gh/hosts.yml)
89fn get_token_from_gh_config() -> Result<String> {
90    let config_path = dirs::home_dir()
91        .ok_or_else(|| MiyabiError::Auth("Could not determine home directory".to_string()))?
92        .join(".config")
93        .join("gh")
94        .join("hosts.yml");
95
96    if !config_path.exists() {
97        return Err(MiyabiError::Auth("gh config file not found".to_string()));
98    }
99
100    let content = fs::read_to_string(&config_path)
101        .map_err(|e| MiyabiError::Auth(format!("Failed to read gh config file: {}", e)))?;
102
103    // Parse YAML to extract token
104    // Format: github.com:
105    //           oauth_token: ghp_xxx
106    for line in content.lines() {
107        let trimmed = line.trim();
108        if trimmed.starts_with("oauth_token:") {
109            if let Some(token) = trimmed.split(':').nth(1) {
110                let token = token.trim().to_string();
111                if token.starts_with("ghp_") {
112                    return Ok(token);
113                }
114            }
115        }
116    }
117
118    Err(MiyabiError::Auth("No oauth_token found in gh config file".to_string()))
119}
120
121/// Validate that a token looks correct (starts with ghp_)
122///
123/// This is a simple format check, not a full validation against GitHub API.
124pub fn validate_token_format(token: &str) -> Result<()> {
125    if token.is_empty() {
126        return Err(MiyabiError::Auth("Token is empty".to_string()));
127    }
128
129    if !token.starts_with("ghp_") && !token.starts_with("gho_") && !token.starts_with("ghs_") {
130        return Err(MiyabiError::Auth(
131            "Token does not start with expected prefix (ghp_, gho_, or ghs_)".to_string(),
132        ));
133    }
134
135    if token.len() < 20 {
136        return Err(MiyabiError::Auth(
137            "Token is too short (expected at least 20 characters)".to_string(),
138        ));
139    }
140
141    Ok(())
142}
143
144/// Check if GitHub CLI is installed and authenticated
145pub fn check_gh_cli_status() -> GhCliStatus {
146    // Check if gh is installed
147    let output = Command::new("which").arg("gh").output();
148    if output.is_err() || !output.as_ref().unwrap().status.success() {
149        return GhCliStatus::NotInstalled;
150    }
151
152    // Check if authenticated
153    let output = Command::new("gh").args(["auth", "status"]).output();
154    if let Ok(output) = output {
155        if output.status.success() {
156            return GhCliStatus::Authenticated;
157        }
158    }
159
160    GhCliStatus::NotAuthenticated
161}
162
163/// GitHub CLI status
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum GhCliStatus {
166    /// gh CLI is not installed
167    NotInstalled,
168    /// gh CLI is installed but not authenticated
169    NotAuthenticated,
170    /// gh CLI is installed and authenticated
171    Authenticated,
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_validate_token_format_valid() {
180        assert!(validate_token_format("ghp_1234567890abcdefghij").is_ok());
181        assert!(validate_token_format("gho_1234567890abcdefghij").is_ok());
182        assert!(validate_token_format("ghs_1234567890abcdefghij").is_ok());
183    }
184
185    #[test]
186    fn test_validate_token_format_invalid() {
187        assert!(validate_token_format("").is_err());
188        assert!(validate_token_format("invalid").is_err());
189        assert!(validate_token_format("ghp_123").is_err()); // Too short
190        assert!(validate_token_format("xyz_1234567890abcdefghij").is_err());
191    }
192
193    #[test]
194    fn test_gh_cli_status() {
195        // Just ensure it doesn't panic
196        let status = check_gh_cli_status();
197        // Status will vary by environment, so just check it's one of the valid variants
198        assert!(matches!(
199            status,
200            GhCliStatus::NotInstalled | GhCliStatus::NotAuthenticated | GhCliStatus::Authenticated
201        ));
202    }
203
204    #[test]
205    fn test_discover_token_doesnt_panic() {
206        // Just ensure it doesn't panic (may error if no token is set)
207        let _ = discover_token();
208    }
209}