use crate::config::ReviewConfig;
use crate::integrations::github::auth::app::resolve_app_token;
use crate::integrations::github::{GithubClient, GithubError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
Cli,
Serve,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthStrategy {
Cli,
App,
}
pub const AUTH_MODE_ENV: &str = "TRUSTY_REVIEW_AUTH_MODE";
impl AuthStrategy {
pub fn select(mode: RunMode, override_flag: Option<&str>) -> Self {
if let Some(forced) = override_flag.and_then(Self::parse_override) {
return forced;
}
if let Ok(env_val) = std::env::var(AUTH_MODE_ENV)
&& let Some(forced) = Self::parse_override(&env_val)
{
return forced;
}
match mode {
RunMode::Cli => AuthStrategy::Cli,
RunMode::Serve => AuthStrategy::App,
}
}
fn parse_override(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"cli" | "pat" | "gh" | "token" => Some(AuthStrategy::Cli),
"app" | "github_app" | "github-app" => Some(AuthStrategy::App),
_ => None,
}
}
pub async fn resolve_token(
self,
client: &GithubClient,
config: &ReviewConfig,
owner: &str,
) -> Result<String, GithubError> {
match self {
AuthStrategy::Cli => resolve_cli_token(config, &SystemGhResolver),
AuthStrategy::App => {
resolve_app_token(
client,
config.github_app_id.as_deref(),
config.github_app_private_key.as_deref(),
&config.github_installations,
owner,
)
.await
}
}
}
}
pub trait GhTokenResolver {
fn gh_auth_token(&self) -> Option<String>;
}
pub struct SystemGhResolver;
impl GhTokenResolver for SystemGhResolver {
fn gh_auth_token(&self) -> Option<String> {
let output = std::process::Command::new("gh")
.args(["auth", "token"])
.output()
.map_err(|e| tracing::debug!("`gh auth token` could not be spawned: {e}"))
.ok()?;
if !output.status.success() {
tracing::debug!(
status = ?output.status.code(),
"`gh auth token` exited non-zero (not logged in?)"
);
return None;
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() { None } else { Some(token) }
}
}
fn resolve_cli_token(
config: &ReviewConfig,
gh: &impl GhTokenResolver,
) -> Result<String, GithubError> {
if !config.github_token.is_empty() {
tracing::debug!("using GITHUB_TOKEN for CLI GitHub auth");
return Ok(config.github_token.clone());
}
if let Ok(gh_token) = std::env::var("GH_TOKEN")
&& !gh_token.trim().is_empty()
{
tracing::debug!("using GH_TOKEN for CLI GitHub auth");
return Ok(gh_token.trim().to_string());
}
if let Some(token) = gh.gh_auth_token() {
tracing::debug!("using `gh auth token` for CLI GitHub auth");
return Ok(token);
}
Err(GithubError::MissingToken)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
struct FakeGh(Option<String>);
impl GhTokenResolver for FakeGh {
fn gh_auth_token(&self) -> Option<String> {
self.0.clone()
}
}
fn config_with_token(token: &str) -> ReviewConfig {
let mut c = ReviewConfig::load(None);
c.github_token = token.to_string();
c
}
#[test]
#[serial]
fn select_cli_defaults_to_cli_strategy() {
unsafe { std::env::remove_var(AUTH_MODE_ENV) };
assert_eq!(AuthStrategy::select(RunMode::Cli, None), AuthStrategy::Cli);
}
#[test]
#[serial]
fn select_serve_defaults_to_app() {
unsafe { std::env::remove_var(AUTH_MODE_ENV) };
assert_eq!(
AuthStrategy::select(RunMode::Serve, None),
AuthStrategy::App
);
}
#[test]
#[serial]
fn select_override_forces_app() {
unsafe { std::env::remove_var(AUTH_MODE_ENV) };
assert_eq!(
AuthStrategy::select(RunMode::Cli, Some("app")),
AuthStrategy::App
);
}
#[test]
#[serial]
fn select_override_forces_cli() {
unsafe { std::env::remove_var(AUTH_MODE_ENV) };
assert_eq!(
AuthStrategy::select(RunMode::Serve, Some("pat")),
AuthStrategy::Cli
);
}
#[test]
#[serial]
fn select_env_forces_app() {
unsafe { std::env::set_var(AUTH_MODE_ENV, "github_app") };
assert_eq!(AuthStrategy::select(RunMode::Cli, None), AuthStrategy::App);
unsafe { std::env::remove_var(AUTH_MODE_ENV) };
}
#[test]
#[serial]
fn select_flag_beats_env() {
unsafe { std::env::set_var(AUTH_MODE_ENV, "app") };
assert_eq!(
AuthStrategy::select(RunMode::Serve, Some("cli")),
AuthStrategy::Cli
);
unsafe { std::env::remove_var(AUTH_MODE_ENV) };
}
#[test]
fn select_garbage_override_falls_through_to_mode() {
assert_eq!(
AuthStrategy::select(RunMode::Cli, Some("nonsense")),
AuthStrategy::Cli
);
}
#[test]
#[serial]
fn cli_token_prefers_github_token() {
unsafe { std::env::remove_var("GH_TOKEN") };
let config = config_with_token("ghp_from_github_token"); let gh = FakeGh(Some("ghp_from_gh_cli".to_string())); let token = resolve_cli_token(&config, &gh).expect("should resolve");
assert_eq!(token, "ghp_from_github_token");
}
#[test]
#[serial]
fn cli_token_uses_gh_token_env() {
unsafe { std::env::set_var("GH_TOKEN", "ghp_from_gh_token_env") }; let config = config_with_token("");
let gh = FakeGh(Some("ghp_from_gh_cli".to_string())); let token = resolve_cli_token(&config, &gh).expect("should resolve");
assert_eq!(token, "ghp_from_gh_token_env");
unsafe { std::env::remove_var("GH_TOKEN") };
}
#[test]
#[serial]
fn cli_token_falls_back_to_gh() {
unsafe { std::env::remove_var("GH_TOKEN") };
let config = config_with_token("");
let gh = FakeGh(Some("ghp_from_gh_cli".to_string())); let token = resolve_cli_token(&config, &gh).expect("should resolve via gh");
assert_eq!(token, "ghp_from_gh_cli");
}
#[test]
#[serial]
fn cli_token_missing_errors() {
unsafe { std::env::remove_var("GH_TOKEN") };
let config = config_with_token("");
let gh = FakeGh(None);
match resolve_cli_token(&config, &gh) {
Err(GithubError::MissingToken) => {}
other => panic!("expected MissingToken, got {other:?}"),
}
}
#[tokio::test]
async fn app_strategy_requires_credentials() {
let mut config = ReviewConfig::load(None);
config.github_app_id = None;
config.github_app_private_key = None;
let client = GithubClient::new();
let result = AuthStrategy::App
.resolve_token(&client, &config, "acme")
.await;
match result {
Err(GithubError::Auth(_)) => {}
other => panic!("expected Auth error without App creds, got {other:?}"),
}
}
}