travelagent-forge-github 1.11.1

GitHub forge backend for travelagent
Documentation
use travelagent_core::error::{Result, TrvError};

pub fn resolve_token() -> Result<String> {
    if let Ok(token) = std::env::var("GITHUB_TOKEN")
        && !token.is_empty()
    {
        return Ok(token);
    }
    if let Ok(token) = std::env::var("GH_TOKEN")
        && !token.is_empty()
    {
        return Ok(token);
    }
    // Fallback to gh CLI
    let output = std::process::Command::new("gh")
        .args(["auth", "token"])
        .output()
        .map_err(|e| TrvError::AuthError(format!("Failed to run gh auth token: {e}")))?;
    if output.status.success()
        && let Some(token) = parse_gh_auth_token(&String::from_utf8_lossy(&output.stdout))
    {
        return Ok(token);
    }
    Err(TrvError::AuthError(
        "No GitHub token found. Set GITHUB_TOKEN, GH_TOKEN, or run 'gh auth login'.".into(),
    ))
}

/// Parse the stdout of `gh auth token`.
///
/// The `gh` CLI (≥ 2.0) prints the token as a single line on stdout with a
/// trailing newline. Older versions behave the same, but we stay defensive
/// against stray whitespace, blank lines, or ANSI/BOM prefixes that could
/// appear if the CLI is wrapped (e.g. by a credential helper).
///
/// Returns `None` for empty or whitespace-only output.
pub(crate) fn parse_gh_auth_token(stdout: &str) -> Option<String> {
    let token = stdout
        .lines()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .unwrap_or("")
        .trim_start_matches('\u{feff}') // strip BOM if present
        .trim()
        .to_string();
    if token.is_empty() { None } else { Some(token) }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn resolves_github_token_env_var() {
        let _lock = ENV_LOCK.lock().unwrap();
        unsafe {
            std::env::set_var("GITHUB_TOKEN", "test-gh-token-123");
            std::env::remove_var("GH_TOKEN");
        }

        let token = resolve_token().unwrap();
        assert_eq!(token, "test-gh-token-123");

        unsafe {
            std::env::remove_var("GITHUB_TOKEN");
        }
    }

    #[test]
    fn falls_back_to_gh_token() {
        let _lock = ENV_LOCK.lock().unwrap();
        unsafe {
            std::env::remove_var("GITHUB_TOKEN");
            std::env::set_var("GH_TOKEN", "fallback-gh-token");
        }

        let token = resolve_token().unwrap();
        assert_eq!(token, "fallback-gh-token");

        unsafe {
            std::env::remove_var("GH_TOKEN");
        }
    }

    #[test]
    fn skips_empty_github_token() {
        let _lock = ENV_LOCK.lock().unwrap();
        unsafe {
            std::env::set_var("GITHUB_TOKEN", "");
            std::env::set_var("GH_TOKEN", "nonempty-token");
        }

        let token = resolve_token().unwrap();
        assert_eq!(token, "nonempty-token");

        unsafe {
            std::env::remove_var("GITHUB_TOKEN");
            std::env::remove_var("GH_TOKEN");
        }
    }

    #[test]
    fn github_token_takes_priority_over_gh_token() {
        let _lock = ENV_LOCK.lock().unwrap();
        unsafe {
            std::env::set_var("GITHUB_TOKEN", "primary");
            std::env::set_var("GH_TOKEN", "secondary");
        }

        let token = resolve_token().unwrap();
        assert_eq!(token, "primary");

        unsafe {
            std::env::remove_var("GITHUB_TOKEN");
            std::env::remove_var("GH_TOKEN");
        }
    }

    #[test]
    fn parse_gh_token_current_format() {
        // gh 2.x prints the token followed by a single newline
        let stdout = "ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789\n";
        assert_eq!(
            parse_gh_auth_token(stdout).as_deref(),
            Some("ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789")
        );
    }

    #[test]
    fn parse_gh_token_fine_grained() {
        // Fine-grained PAT prefix introduced in newer gh versions
        let stdout = "github_pat_11AAAA0000BBBB1111CCCC2222DDDD3333\n";
        assert_eq!(
            parse_gh_auth_token(stdout).as_deref(),
            Some("github_pat_11AAAA0000BBBB1111CCCC2222DDDD3333")
        );
    }

    #[test]
    fn parse_gh_token_tolerates_older_format_without_trailing_newline() {
        // Older gh builds emitted the token without a trailing newline
        let stdout = "ghp_LegacyTokenNoNewline";
        assert_eq!(
            parse_gh_auth_token(stdout).as_deref(),
            Some("ghp_LegacyTokenNoNewline")
        );
    }

    #[test]
    fn parse_gh_token_strips_bom_and_blank_lines() {
        // Defensive against credential-helper wrappers that prepend BOM or blank lines
        let stdout = "\n\n\u{feff}gho_WrappedToken123\n";
        assert_eq!(
            parse_gh_auth_token(stdout).as_deref(),
            Some("gho_WrappedToken123")
        );
    }

    #[test]
    fn parse_gh_token_returns_none_for_empty_output() {
        assert_eq!(parse_gh_auth_token(""), None);
        assert_eq!(parse_gh_auth_token("\n\n  \n"), None);
    }

    #[test]
    fn returns_error_when_no_token_and_cli_unavailable() {
        let _lock = ENV_LOCK.lock().unwrap();
        unsafe {
            std::env::remove_var("GITHUB_TOKEN");
            std::env::remove_var("GH_TOKEN");
            // Force gh CLI to fail by pointing PATH to empty dir
            std::env::set_var("PATH", "/nonexistent");
        }

        let result = resolve_token();
        assert!(result.is_err());

        // Restore PATH (best-effort; tests run with mutex so ordering is safe)
        unsafe {
            std::env::set_var("PATH", "/usr/bin:/usr/local/bin:/bin");
        }
    }
}