Skip to main content

orch/github/
token.rs

1//! Centralized GitHub token resolution — supports env vars, GitHub App JWT, and gh CLI fallback.
2//!
3//! This module provides a [`TokenResolver`] that caches token resolution and supports multiple modes:
4//! - `env` (default): Read from `GH_TOKEN` or `GITHUB_TOKEN` env vars, then `gh.auth.token` config,
5//!   then `gh auth token` CLI (controlled by `allow_gh_fallback`, which defaults to `true`)
6//! - `github_app`: Generate JWT from app_id + private_key (for GitHub App authentication)
7//!
8//! ## Default behavior
9//!
10//! Out of the box, `TokenResolver::default()` works with just `gh auth login` — no extra config needed.
11//! Resolution order:
12//! 1. `GH_TOKEN` environment variable
13//! 2. `GITHUB_TOKEN` environment variable
14//! 3. `gh.auth.token` config value
15//! 4. `gh auth token` CLI (via `allow_gh_fallback = true`)
16
17use anyhow::Context;
18use std::env;
19use std::sync::{Arc, OnceLock};
20use std::time::{SystemTime, UNIX_EPOCH};
21use tokio::sync::RwLock;
22
23/// Token source mode for GitHub authentication.
24#[derive(Debug, Clone, PartialEq, Default)]
25pub enum TokenMode {
26    /// Read from environment variables and config, with optional `gh auth token` fallback.
27    ///
28    /// Resolution order:
29    /// 1. `GH_TOKEN` env var
30    /// 2. `GITHUB_TOKEN` env var
31    /// 3. `gh.auth.token` config value
32    /// 4. `gh auth token` CLI (if `allow_gh_fallback` is enabled, which is the default)
33    #[default]
34    Env,
35    /// GitHub App authentication with JWT generation.
36    GitHubApp {
37        /// GitHub App ID.
38        app_id: String,
39        /// Path to the private key file (.pem).
40        private_key_path: String,
41    },
42}
43
44impl TokenMode {
45    /// Parse token mode from configuration string.
46    ///
47    /// Supported values:
48    /// - `"env"` → `TokenMode::Env`
49    /// - `"github_app"` → `TokenMode::GitHubApp` (requires `app_id` and `private_key_path` config)
50    #[allow(dead_code)]
51    pub fn from_config(
52        mode: &str,
53        app_id: Option<String>,
54        private_key_path: Option<String>,
55    ) -> Self {
56        match mode {
57            "github_app" => {
58                if let (Some(app_id), Some(key_path)) = (app_id, private_key_path) {
59                    TokenMode::GitHubApp {
60                        app_id,
61                        private_key_path: key_path,
62                    }
63                } else {
64                    tracing::warn!(
65                        "github_app mode requires app_id and private_key_path, falling back to env"
66                    );
67                    TokenMode::Env
68                }
69            }
70            _ => TokenMode::Env,
71        }
72    }
73}
74
75/// Cached token with optional expiration (for GitHub App JWTs).
76#[derive(Debug, Clone)]
77struct CachedToken {
78    token: String,
79    /// Expiration time as Unix epoch seconds (None for non-expiring tokens).
80    expires_at: Option<u64>,
81}
82
83/// Centralized token resolver for GitHub authentication.
84///
85/// The resolver caches tokens and supports async resolution for modes that
86/// may require I/O (e.g., reading private key files).
87///
88/// ## Default setup
89///
90/// ```rust,ignore
91/// let resolver = TokenResolver::default(); // uses gh auth token if no env var set
92/// let token = resolver.get_token().await?;
93/// ```
94#[derive(Debug, Clone)]
95pub struct TokenResolver {
96    mode: TokenMode,
97    /// Whether to fall back to `gh auth token` CLI when env vars and config are unset.
98    ///
99    /// Defaults to `true` — just run `gh auth login` and everything works.
100    /// Set to `false` only when you want to enforce explicit token configuration.
101    allow_gh_fallback: bool,
102    /// Cached token with expiration tracking.
103    cached: Arc<RwLock<Option<CachedToken>>>,
104}
105
106impl TokenResolver {
107    /// Create a new TokenResolver with the specified mode.
108    ///
109    /// `allow_gh_fallback` defaults to `true` — `gh auth token` is tried last if env vars
110    /// and config are not set.
111    pub fn new(mode: TokenMode) -> Self {
112        Self {
113            mode,
114            allow_gh_fallback: true,
115            cached: Arc::new(RwLock::new(None)),
116        }
117    }
118
119    /// Create a TokenResolver from configuration values.
120    #[allow(dead_code)]
121    pub fn from_config(
122        mode_str: Option<&str>,
123        app_id: Option<String>,
124        private_key_path: Option<String>,
125        allow_gh_fallback: bool,
126    ) -> Self {
127        let mode = TokenMode::from_config(mode_str.unwrap_or("env"), app_id, private_key_path);
128        Self {
129            mode,
130            allow_gh_fallback,
131            cached: Arc::new(RwLock::new(None)),
132        }
133    }
134
135    /// Create a default TokenResolver using environment variables with gh CLI fallback.
136    pub fn default_env() -> Self {
137        Self::new(TokenMode::Env)
138    }
139
140    /// Enable or disable `gh auth token` CLI fallback.
141    ///
142    /// Defaults to `true` — disable only to enforce explicit token configuration.
143    #[allow(dead_code)]
144    pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
145        self.allow_gh_fallback = enabled;
146        self
147    }
148
149    /// Resolve the GitHub token asynchronously.
150    ///
151    /// Returns `Ok(Some(token))` if a token is available,
152    /// `Ok(None)` if no token could be resolved,
153    /// or `Err` if an error occurred during resolution.
154    pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
155        // Check cache first
156        {
157            let cached = self.cached.read().await;
158            if let Some(ref entry) = *cached {
159                // Check expiration if present
160                if let Some(expires_at) = entry.expires_at {
161                    let now = SystemTime::now()
162                        .duration_since(UNIX_EPOCH)
163                        .unwrap_or_default()
164                        .as_secs();
165                    if now < expires_at {
166                        return Ok(Some(entry.token.clone()));
167                    }
168                    // Token expired, will re-resolve
169                } else {
170                    // No expiration, use cached token
171                    return Ok(Some(entry.token.clone()));
172                }
173            }
174        }
175
176        // Resolve fresh token
177        let token = self.resolve_fresh().await?;
178
179        // Cache the result if we got a token
180        if let Some(ref t) = token {
181            let expires_at = self.calculate_expiration();
182            let mut cached = self.cached.write().await;
183            *cached = Some(CachedToken {
184                token: t.clone(),
185                expires_at,
186            });
187        }
188
189        Ok(token)
190    }
191
192    /// Get token synchronously (for contexts where async is not available).
193    ///
194    /// Works for Env mode (env vars, config, and gh CLI fallback).
195    /// GitHub App mode requires async due to file reading.
196    #[allow(dead_code)]
197    pub fn get_token_sync(&self) -> anyhow::Result<Option<String>> {
198        match self.mode {
199            TokenMode::Env => {
200                // Try env vars + config first
201                if let Some(t) = Self::resolve_env_token() {
202                    return Ok(Some(t));
203                }
204                // gh CLI fallback
205                if self.allow_gh_fallback {
206                    return Self::resolve_gh_cli_token();
207                }
208                Ok(None)
209            }
210            TokenMode::GitHubApp { .. } => {
211                // GitHub App requires async due to file I/O
212                Err(anyhow::anyhow!(
213                    "GitHub App token resolution requires async context, use get_token() instead"
214                ))
215            }
216        }
217    }
218
219    /// Clear the cached token (useful when token is known to be invalid).
220    #[allow(dead_code)]
221    pub async fn clear_cache(&self) {
222        let mut cached = self.cached.write().await;
223        *cached = None;
224    }
225
226    /// Get the current token mode.
227    #[allow(dead_code)]
228    pub fn mode(&self) -> &TokenMode {
229        &self.mode
230    }
231
232    /// Resolve a fresh token based on the configured mode.
233    async fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
234        // Try primary mode first
235        let token = match &self.mode {
236            TokenMode::Env => Self::resolve_env_token(),
237            TokenMode::GitHubApp {
238                app_id,
239                private_key_path,
240            } => {
241                self.resolve_github_app_token(app_id, private_key_path)
242                    .await?
243            }
244        };
245
246        // Return if we got a token from primary mode
247        if token.is_some() {
248            return Ok(token);
249        }
250
251        // gh CLI fallback — the default when nothing else is configured
252        if self.allow_gh_fallback {
253            tracing::debug!("No token in env/config — trying gh auth token CLI");
254            return Self::resolve_gh_cli_token();
255        }
256
257        Ok(None)
258    }
259
260    /// Resolve token from environment variables and config.
261    ///
262    /// Checks (in order):
263    /// 1. `GH_TOKEN` env var
264    /// 2. `GITHUB_TOKEN` env var
265    /// 3. `gh.auth.token` config value
266    fn resolve_env_token() -> Option<String> {
267        if let Ok(t) = env::var("GH_TOKEN") {
268            if !t.is_empty() {
269                tracing::debug!("Resolved GitHub token from GH_TOKEN environment variable");
270                return Some(t);
271            }
272        }
273        if let Ok(t) = env::var("GITHUB_TOKEN") {
274            if !t.is_empty() {
275                tracing::debug!("Resolved GitHub token from GITHUB_TOKEN environment variable");
276                return Some(t);
277            }
278        }
279        // Config-based token (explicit override in ~/.orch/config.yml)
280        if let Ok(t) = crate::config::get("gh.auth.token") {
281            if !t.is_empty() {
282                tracing::debug!("Resolved GitHub token from gh.auth.token config");
283                return Some(t);
284            }
285        }
286        None
287    }
288
289    /// Resolve token using `gh auth token` CLI.
290    fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
291        tracing::debug!("Attempting to resolve GitHub token via gh CLI");
292
293        // Try gh in PATH first
294        if let Some(token) = Self::try_gh_command("gh")? {
295            return Ok(Some(token));
296        }
297
298        // Try common gh install paths for launchd/Homebrew environments
299        for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
300            if let Some(token) = Self::try_gh_command(path)? {
301                return Ok(Some(token));
302            }
303        }
304
305        Ok(None)
306    }
307
308    /// Try to get token from a specific gh binary path.
309    ///
310    /// # Why `std::process::Command` (blocking) is intentional here
311    ///
312    /// This looks like a "blocking call in async context" antipattern, but it is not a problem:
313    /// - `resolve_fresh()` is only called from `get_token()` under a write-lock
314    /// - The result is immediately cached: env tokens forever, gh CLI tokens for 1 hour
315    /// - This executes AT MOST once per hour, typically once at startup
316    /// - A one-time ~100ms call does not meaningfully block the Tokio runtime
317    ///
318    /// DO NOT replace this with `spawn_blocking` or `tokio::process::Command` —
319    /// the added complexity is not justified and previous attempts to "fix" this
320    /// broke the auth flow. See issues #418 and #421 (closed as invalid).
321    fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
322        match std::process::Command::new(gh_path)
323            .args(["auth", "token"])
324            .output()
325        {
326            Ok(out) if out.status.success() => {
327                let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
328                if !token.is_empty() {
329                    tracing::debug!(gh_path, "Resolved GitHub token via gh CLI");
330                    return Ok(Some(token));
331                }
332            }
333            Ok(out) => {
334                let stderr = String::from_utf8_lossy(&out.stderr);
335                tracing::debug!(gh_path, %stderr, "gh auth token failed");
336            }
337            Err(e) => {
338                tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
339            }
340        }
341        Ok(None)
342    }
343
344    /// Resolve token for GitHub App (JWT-based).
345    async fn resolve_github_app_token(
346        &self,
347        app_id: &str,
348        private_key_path: &str,
349    ) -> anyhow::Result<Option<String>> {
350        tracing::debug!(app_id, private_key_path, "Generating GitHub App JWT");
351
352        // Read the private key
353        let private_key = tokio::fs::read_to_string(private_key_path)
354            .await
355            .with_context(|| format!("Failed to read private key from {private_key_path}"))?;
356
357        // Generate JWT
358        let jwt = generate_github_app_jwt(app_id, &private_key)?;
359
360        // Exchange JWT for installation token (requires installation_id)
361        // For now, just return the JWT (this is a placeholder for full implementation)
362        tracing::warn!(
363            "GitHub App JWT generation is available but token exchange requires installation_id"
364        );
365
366        Ok(Some(jwt))
367    }
368
369    /// Calculate expiration time for cached tokens.
370    fn calculate_expiration(&self) -> Option<u64> {
371        match self.mode {
372            // GitHub App JWTs expire (typically 10 minutes)
373            TokenMode::GitHubApp { .. } => {
374                let now = SystemTime::now()
375                    .duration_since(UNIX_EPOCH)
376                    .unwrap_or_default()
377                    .as_secs();
378                // JWT valid for 9 minutes (GitHub allows up to 10)
379                Some(now + 540)
380            }
381            // Env tokens don't expire (managed externally)
382            _ => None,
383        }
384    }
385}
386
387/// Process-wide shared [`TokenResolver`].
388///
389/// Initialized once on first call. All callers (GhHttp, agent runner, etc.)
390/// share the same instance so the token is resolved once — via GH_TOKEN,
391/// GITHUB_TOKEN, or `gh auth token` CLI — and cached for the life of the process.
392static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
393
394/// Get the process-wide shared token resolver.
395pub fn shared() -> Arc<TokenResolver> {
396    SHARED_RESOLVER
397        .get_or_init(|| Arc::new(TokenResolver::default_env()))
398        .clone()
399}
400
401impl Default for TokenResolver {
402    fn default() -> Self {
403        Self::default_env()
404    }
405}
406
407/// Generate a JWT for GitHub App authentication.
408///
409/// This creates a signed JWT using the app's private key (RS256).
410/// The JWT can then be used to authenticate as the app or exchange for
411/// an installation access token.
412fn generate_github_app_jwt(app_id: &str, private_key_pem: &str) -> anyhow::Result<String> {
413    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
414    use serde::{Deserialize, Serialize};
415
416    #[derive(Debug, Serialize, Deserialize)]
417    struct Claims {
418        /// Issued at time (Unix timestamp)
419        iat: u64,
420        /// Expiration time (Unix timestamp, max 10 minutes from iat)
421        exp: u64,
422        /// GitHub App ID
423        iss: String,
424    }
425
426    let now = SystemTime::now()
427        .duration_since(UNIX_EPOCH)
428        .unwrap_or_default()
429        .as_secs();
430
431    let claims = Claims {
432        iat: now - 60,  // Allow 60 seconds clock skew
433        exp: now + 540, // 9 minutes expiration
434        iss: app_id.to_string(),
435    };
436
437    let header = Header::new(Algorithm::RS256);
438    let encoding_key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
439        .context("Failed to parse private key PEM")?;
440
441    let token = encode(&header, &claims, &encoding_key).context("Failed to encode JWT")?;
442
443    Ok(token)
444}
445
446/// Create a global token resolver from configuration.
447///
448/// Resolution order:
449/// 1. `GH_TOKEN` / `GITHUB_TOKEN` env vars
450/// 2. `gh.auth.token` config value
451/// 3. `gh auth token` CLI (unless `gh.allow_gh_fallback = false`)
452///
453/// This is typically called once at application startup.
454#[allow(dead_code)]
455pub fn create_token_resolver() -> TokenResolver {
456    let mode = crate::config::get("github.token_mode").ok();
457    let app_id = crate::config::get("github.app_id").ok();
458    let private_key_path = crate::config::get("github.private_key_path").ok();
459    // allow_gh_fallback defaults to true — gh auth login is all you need
460    let allow_gh_fallback = crate::config::get("gh.allow_gh_fallback")
461        .ok()
462        .map(|s| s != "false" && s != "0")
463        .unwrap_or(true);
464
465    TokenResolver::from_config(mode.as_deref(), app_id, private_key_path, allow_gh_fallback)
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use once_cell::sync::Lazy;
472    use std::sync::Mutex;
473
474    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
475
476    #[test]
477    fn token_mode_from_config_env() {
478        let mode = TokenMode::from_config("env", None, None);
479        assert!(matches!(mode, TokenMode::Env));
480    }
481
482    #[test]
483    fn token_mode_from_config_unknown_defaults_to_env() {
484        let mode = TokenMode::from_config("unknown", None, None);
485        assert!(matches!(mode, TokenMode::Env));
486    }
487
488    #[test]
489    fn token_mode_from_config_github_app() {
490        let mode = TokenMode::from_config(
491            "github_app",
492            Some("123456".to_string()),
493            Some("/path/to/key.pem".to_string()),
494        );
495        assert!(matches!(mode, TokenMode::GitHubApp { .. }));
496    }
497
498    #[test]
499    fn token_mode_from_config_github_app_missing_params() {
500        // Should fall back to env if app_id or private_key_path is missing
501        let mode = TokenMode::from_config("github_app", Some("123456".to_string()), None);
502        assert!(matches!(mode, TokenMode::Env));
503
504        let mode = TokenMode::from_config("github_app", None, Some("/path/to/key.pem".to_string()));
505        assert!(matches!(mode, TokenMode::Env));
506    }
507
508    #[test]
509    fn token_resolver_default_is_env() {
510        let resolver = TokenResolver::default();
511        assert!(matches!(resolver.mode(), TokenMode::Env));
512    }
513
514    #[test]
515    fn token_resolver_new_env() {
516        let resolver = TokenResolver::new(TokenMode::Env);
517        assert!(matches!(resolver.mode(), TokenMode::Env));
518    }
519
520    #[test]
521    fn token_resolver_gh_fallback_enabled_by_default() {
522        let resolver = TokenResolver::new(TokenMode::Env);
523        assert!(resolver.allow_gh_fallback);
524    }
525
526    #[test]
527    fn token_resolver_with_gh_fallback() {
528        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
529        assert!(!resolver.allow_gh_fallback);
530    }
531
532    #[allow(clippy::await_holding_lock)]
533    #[tokio::test(flavor = "current_thread")]
534    async fn get_token_returns_none_when_no_env_set() {
535        let _guard = ENV_LOCK.lock().unwrap();
536        env::remove_var("GH_TOKEN");
537        env::remove_var("GITHUB_TOKEN");
538        // Disable gh fallback so we don't actually call gh CLI in tests
539        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
540        resolver.clear_cache().await;
541
542        let token = resolver.get_token().await.unwrap();
543        assert!(token.is_none());
544    }
545
546    #[allow(clippy::await_holding_lock)]
547    #[tokio::test(flavor = "current_thread")]
548    async fn get_token_prefers_gh_token() {
549        let _guard = ENV_LOCK.lock().unwrap();
550        env::remove_var("GH_TOKEN");
551        env::remove_var("GITHUB_TOKEN");
552        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
553        resolver.clear_cache().await;
554
555        env::set_var("GH_TOKEN", "gh_token_value");
556        env::remove_var("GITHUB_TOKEN");
557
558        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
559        let token = resolver.get_token().await.unwrap();
560        assert_eq!(token, Some("gh_token_value".to_string()));
561
562        env::remove_var("GH_TOKEN");
563    }
564
565    #[allow(clippy::await_holding_lock)]
566    #[tokio::test(flavor = "current_thread")]
567    async fn get_token_falls_back_to_github_token() {
568        let _guard = ENV_LOCK.lock().unwrap();
569        env::remove_var("GH_TOKEN");
570        env::remove_var("GITHUB_TOKEN");
571        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
572        resolver.clear_cache().await;
573
574        env::set_var("GITHUB_TOKEN", "github_token_value");
575
576        let token = resolver.get_token().await.unwrap();
577        assert_eq!(token, Some("github_token_value".to_string()));
578
579        env::remove_var("GITHUB_TOKEN");
580    }
581
582    #[allow(clippy::await_holding_lock)]
583    #[tokio::test(flavor = "current_thread")]
584    async fn get_token_prefers_gh_token_over_github_token() {
585        let _guard = ENV_LOCK.lock().unwrap();
586        env::remove_var("GH_TOKEN");
587        env::remove_var("GITHUB_TOKEN");
588        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
589        resolver.clear_cache().await;
590
591        env::set_var("GH_TOKEN", "gh_token_value");
592        env::set_var("GITHUB_TOKEN", "github_token_value");
593
594        let token = resolver.get_token().await.unwrap();
595        // GH_TOKEN should be preferred
596        assert_eq!(token, Some("gh_token_value".to_string()));
597
598        env::remove_var("GH_TOKEN");
599        env::remove_var("GITHUB_TOKEN");
600    }
601
602    #[allow(clippy::await_holding_lock)]
603    #[tokio::test(flavor = "current_thread")]
604    async fn get_token_ignores_empty_env_vars() {
605        let _guard = ENV_LOCK.lock().unwrap();
606        env::remove_var("GH_TOKEN");
607        env::remove_var("GITHUB_TOKEN");
608        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
609        resolver.clear_cache().await;
610
611        env::set_var("GH_TOKEN", "");
612        env::set_var("GITHUB_TOKEN", "");
613
614        let token = resolver.get_token().await.unwrap();
615        assert!(token.is_none());
616
617        env::remove_var("GH_TOKEN");
618        env::remove_var("GITHUB_TOKEN");
619    }
620
621    #[allow(clippy::await_holding_lock)]
622    #[tokio::test(flavor = "current_thread")]
623    async fn get_token_sync_env_mode() {
624        let _guard = ENV_LOCK.lock().unwrap();
625        env::remove_var("GH_TOKEN");
626        env::remove_var("GITHUB_TOKEN");
627        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
628        resolver.clear_cache().await;
629
630        env::set_var("GH_TOKEN", "sync_test_token");
631
632        let token = resolver.get_token_sync().unwrap();
633        assert_eq!(token, Some("sync_test_token".to_string()));
634
635        env::remove_var("GH_TOKEN");
636    }
637
638    #[test]
639    fn get_token_sync_github_app_requires_async() {
640        let resolver = TokenResolver::new(TokenMode::GitHubApp {
641            app_id: "123".to_string(),
642            private_key_path: "/path/to/key.pem".to_string(),
643        });
644
645        let result = resolver.get_token_sync();
646        assert!(result.is_err());
647        assert!(result.unwrap_err().to_string().contains("async"));
648    }
649
650    #[allow(clippy::await_holding_lock)]
651    #[tokio::test(flavor = "current_thread")]
652    async fn clear_cache_clears_token() {
653        let _guard = ENV_LOCK.lock().unwrap();
654        env::remove_var("GH_TOKEN");
655        env::remove_var("GITHUB_TOKEN");
656        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
657        resolver.clear_cache().await;
658
659        env::set_var("GH_TOKEN", "cached_token");
660
661        // First call should cache the token
662        let token1 = resolver.get_token().await.unwrap();
663        assert_eq!(token1, Some("cached_token".to_string()));
664
665        // Clear cache
666        resolver.clear_cache().await;
667
668        // Token should still resolve (fresh lookup)
669        let token2 = resolver.get_token().await.unwrap();
670        assert_eq!(token2, Some("cached_token".to_string()));
671
672        env::remove_var("GH_TOKEN");
673    }
674
675    #[test]
676    fn resolve_env_token_returns_none_when_unset() {
677        let _guard = ENV_LOCK.lock().unwrap();
678        env::remove_var("GH_TOKEN");
679        env::remove_var("GITHUB_TOKEN");
680
681        let token = TokenResolver::resolve_env_token();
682        assert!(token.is_none());
683    }
684
685    #[test]
686    fn resolve_env_token_prefers_gh_token() {
687        let _guard = ENV_LOCK.lock().unwrap();
688        env::set_var("GH_TOKEN", "gh_pref");
689        env::set_var("GITHUB_TOKEN", "github_pref");
690
691        let token = TokenResolver::resolve_env_token();
692        assert_eq!(token, Some("gh_pref".to_string()));
693
694        env::remove_var("GH_TOKEN");
695        env::remove_var("GITHUB_TOKEN");
696    }
697}