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(
86        "gh CLI is available but not authenticated".to_string(),
87    ))
88}
89
90/// Get token from gh config file (~/.config/gh/hosts.yml)
91fn get_token_from_gh_config() -> Result<String> {
92    let config_path = dirs::home_dir()
93        .ok_or_else(|| MiyabiError::Auth("Could not determine home directory".to_string()))?
94        .join(".config")
95        .join("gh")
96        .join("hosts.yml");
97
98    if !config_path.exists() {
99        return Err(MiyabiError::Auth("gh config file not found".to_string()));
100    }
101
102    let content = fs::read_to_string(&config_path)
103        .map_err(|e| MiyabiError::Auth(format!("Failed to read gh config file: {}", e)))?;
104
105    // Parse YAML to extract token
106    // Format: github.com:
107    //           oauth_token: ghp_xxx
108    for line in content.lines() {
109        let trimmed = line.trim();
110        if trimmed.starts_with("oauth_token:") {
111            if let Some(token) = trimmed.split(':').nth(1) {
112                let token = token.trim().to_string();
113                if token.starts_with("ghp_") {
114                    return Ok(token);
115                }
116            }
117        }
118    }
119
120    Err(MiyabiError::Auth(
121        "No oauth_token found in gh config file".to_string(),
122    ))
123}
124
125/// Validate that a token looks correct (starts with ghp_)
126///
127/// This is a simple format check, not a full validation against GitHub API.
128pub fn validate_token_format(token: &str) -> Result<()> {
129    if token.is_empty() {
130        return Err(MiyabiError::Auth("Token is empty".to_string()));
131    }
132
133    if !token.starts_with("ghp_") && !token.starts_with("gho_") && !token.starts_with("ghs_") {
134        return Err(MiyabiError::Auth(
135            "Token does not start with expected prefix (ghp_, gho_, or ghs_)".to_string(),
136        ));
137    }
138
139    if token.len() < 20 {
140        return Err(MiyabiError::Auth(
141            "Token is too short (expected at least 20 characters)".to_string(),
142        ));
143    }
144
145    Ok(())
146}
147
148/// Check if GitHub CLI is installed and authenticated
149pub fn check_gh_cli_status() -> GhCliStatus {
150    // Check if gh is installed
151    let output = Command::new("which").arg("gh").output();
152    if output.is_err() || !output.as_ref().unwrap().status.success() {
153        return GhCliStatus::NotInstalled;
154    }
155
156    // Check if authenticated
157    let output = Command::new("gh").args(["auth", "status"]).output();
158    if let Ok(output) = output {
159        if output.status.success() {
160            return GhCliStatus::Authenticated;
161        }
162    }
163
164    GhCliStatus::NotAuthenticated
165}
166
167/// GitHub CLI status
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum GhCliStatus {
170    /// gh CLI is not installed
171    NotInstalled,
172    /// gh CLI is installed but not authenticated
173    NotAuthenticated,
174    /// gh CLI is installed and authenticated
175    Authenticated,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_validate_token_format_valid() {
184        assert!(validate_token_format("ghp_1234567890abcdefghij").is_ok());
185        assert!(validate_token_format("gho_1234567890abcdefghij").is_ok());
186        assert!(validate_token_format("ghs_1234567890abcdefghij").is_ok());
187    }
188
189    #[test]
190    fn test_validate_token_format_invalid() {
191        assert!(validate_token_format("").is_err());
192        assert!(validate_token_format("invalid").is_err());
193        assert!(validate_token_format("ghp_123").is_err()); // Too short
194        assert!(validate_token_format("xyz_1234567890abcdefghij").is_err());
195    }
196
197    #[test]
198    fn test_gh_cli_status() {
199        // Just ensure it doesn't panic
200        let status = check_gh_cli_status();
201        // Status will vary by environment, so just check it's one of the valid variants
202        assert!(matches!(
203            status,
204            GhCliStatus::NotInstalled | GhCliStatus::NotAuthenticated | GhCliStatus::Authenticated
205        ));
206    }
207
208    #[test]
209    fn test_discover_token_doesnt_panic() {
210        // Just ensure it doesn't panic (may error if no token is set)
211        let _ = discover_token();
212    }
213}