#[cfg(test)]
use once_cell::sync::Lazy;
use std::env;
#[cfg(test)]
use std::sync::Mutex;
use std::sync::{Arc, OnceLock};
use tokio::sync::RwLock;
#[derive(Debug, Clone)]
pub struct TokenResolver {
allow_gh_fallback: bool,
cached: Arc<RwLock<Option<String>>>,
}
impl TokenResolver {
pub fn default_env() -> Self {
Self {
allow_gh_fallback: true,
cached: Arc::new(RwLock::new(None)),
}
}
#[allow(dead_code)]
pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
self.allow_gh_fallback = enabled;
self
}
pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
if let Some(ref t) = *self.cached.read().await {
return Ok(Some(t.clone()));
}
let token = self.resolve_fresh()?;
if let Some(ref t) = token {
*self.cached.write().await = Some(t.clone());
}
Ok(token)
}
pub async fn clear_cache(&self) {
let mut cached = self.cached.write().await;
*cached = None;
}
fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
if let Some(t) = Self::resolve_env_token() {
return Ok(Some(t));
}
if self.allow_gh_fallback {
tracing::debug!("No token in env/config — trying gh auth token CLI");
return Self::resolve_gh_cli_token();
}
Ok(None)
}
fn resolve_env_token() -> Option<String> {
if let Ok(t) = env::var("GH_TOKEN") {
if !t.is_empty() {
tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
return Some(t);
}
}
if let Ok(t) = env::var("GITHUB_TOKEN") {
if !t.is_empty() {
tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
return Some(t);
}
}
if let Ok(t) = crate::config::get("gh.auth.token") {
if !t.is_empty() {
tracing::info!("Resolved GitHub token from gh.auth.token config");
return Some(t);
}
}
None
}
fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
tracing::debug!("Attempting to resolve GitHub token via gh CLI");
if let Some(token) = Self::try_gh_command("gh")? {
return Ok(Some(token));
}
for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
if let Some(token) = Self::try_gh_command(path)? {
return Ok(Some(token));
}
}
Ok(None)
}
fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
match std::process::Command::new(gh_path)
.args(["auth", "token"])
.output()
{
Ok(out) if out.status.success() => {
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !token.is_empty() {
tracing::info!(gh_path, "Resolved GitHub token via gh CLI (keychain)");
return Ok(Some(token));
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::debug!(gh_path, %stderr, "gh auth token failed");
}
Err(e) => {
tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
}
}
Ok(None)
}
}
static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
pub fn shared() -> Arc<TokenResolver> {
SHARED_RESOLVER
.get_or_init(|| Arc::new(TokenResolver::default_env()))
.clone()
}
impl Default for TokenResolver {
fn default() -> Self {
Self::default_env()
}
}
#[cfg(test)]
pub(crate) static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_resolver_gh_fallback_enabled_by_default() {
let resolver = TokenResolver::default_env();
assert!(resolver.allow_gh_fallback);
}
#[test]
fn token_resolver_with_gh_fallback() {
let resolver = TokenResolver::default_env().with_gh_fallback(false);
assert!(!resolver.allow_gh_fallback);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_returns_none_when_no_env_set() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::default_env().with_gh_fallback(false);
resolver.clear_cache().await;
let token = resolver.get_token().await.unwrap();
assert!(token.is_none());
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_prefers_gh_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::default_env().with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "gh_token_value");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::default_env().with_gh_fallback(false);
let token = resolver.get_token().await.unwrap();
assert_eq!(token, Some("gh_token_value".to_string()));
env::remove_var("GH_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_falls_back_to_github_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::default_env().with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GITHUB_TOKEN", "github_token_value");
let token = resolver.get_token().await.unwrap();
assert_eq!(token, Some("github_token_value".to_string()));
env::remove_var("GITHUB_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_prefers_gh_token_over_github_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::default_env().with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "gh_token_value");
env::set_var("GITHUB_TOKEN", "github_token_value");
let token = resolver.get_token().await.unwrap();
assert_eq!(token, Some("gh_token_value".to_string()));
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_ignores_empty_env_vars() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::default_env().with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "");
env::set_var("GITHUB_TOKEN", "");
let token = resolver.get_token().await.unwrap();
assert!(token.is_none());
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn clear_cache_clears_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::default_env().with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "cached_token");
let token1 = resolver.get_token().await.unwrap();
assert_eq!(token1, Some("cached_token".to_string()));
resolver.clear_cache().await;
let token2 = resolver.get_token().await.unwrap();
assert_eq!(token2, Some("cached_token".to_string()));
env::remove_var("GH_TOKEN");
}
#[test]
fn resolve_env_token_returns_none_when_unset() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let token = TokenResolver::resolve_env_token();
assert!(token.is_none());
}
#[test]
fn resolve_env_token_prefers_gh_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::set_var("GH_TOKEN", "gh_pref");
env::set_var("GITHUB_TOKEN", "github_pref");
let token = TokenResolver::resolve_env_token();
assert_eq!(token, Some("gh_pref".to_string()));
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
}
}