agent_reel_identity_github 0.1.0

projection-safe feed for local coding-agent activity
Documentation
use agent_reel_identity::{GithubLogin, GithubUserId, IdentityError, PrincipalRef};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

#[derive(Debug, thiserror::Error)]
pub enum GithubResolveError {
    #[error(transparent)]
    Identity(#[from] IdentityError),
    #[error("github user not found: {0}")]
    NotFound(String),
    #[error("github resolver rate limited")]
    RateLimited,
    #[error("github resolver unavailable: {0}")]
    Unavailable(String),
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubProfile {
    pub id: GithubUserId,
    pub login: GithubLogin,
    pub name: Option<String>,
    pub avatar_url: Option<String>,
}

impl GithubProfile {
    #[must_use]
    pub fn principal(&self) -> PrincipalRef {
        PrincipalRef::github(self.id, self.login.clone())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct GithubUserResponse {
    pub id: u64,
    pub login: String,
    pub name: Option<String>,
    pub avatar_url: Option<String>,
}

impl TryFrom<GithubUserResponse> for GithubProfile {
    type Error = GithubResolveError;

    fn try_from(value: GithubUserResponse) -> Result<Self, Self::Error> {
        Ok(Self {
            id: GithubUserId::new(value.id),
            login: GithubLogin::parse(value.login)?,
            name: value.name,
            avatar_url: value.avatar_url,
        })
    }
}

pub trait GithubResolver {
    fn resolve_login(&self, login: &GithubLogin) -> Result<GithubProfile, GithubResolveError>;
}

#[derive(Clone, Debug, Default)]
pub struct StaticGithubResolver {
    profiles: BTreeMap<String, GithubProfile>,
    rate_limited: bool,
}

impl StaticGithubResolver {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    #[must_use]
    pub fn with_profile(mut self, profile: GithubProfile) -> Self {
        self.profiles.insert(profile.login.normalized(), profile);
        self
    }

    #[must_use]
    pub fn with_alias(mut self, alias: &GithubLogin, profile: GithubProfile) -> Self {
        self.profiles.insert(alias.normalized(), profile);
        self
    }

    #[must_use]
    pub fn rate_limited(mut self) -> Self {
        self.rate_limited = true;
        self
    }
}

impl GithubResolver for StaticGithubResolver {
    fn resolve_login(&self, login: &GithubLogin) -> Result<GithubProfile, GithubResolveError> {
        if self.rate_limited {
            return Err(GithubResolveError::RateLimited);
        }
        self.profiles
            .get(&login.normalized())
            .cloned()
            .ok_or_else(|| GithubResolveError::NotFound(login.to_string()))
    }
}

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

    fn profile(login: &str, id: u64) -> GithubProfile {
        GithubProfile {
            id: GithubUserId::new(id),
            login: GithubLogin::parse(login).expect("login parses"),
            name: Some(login.to_string()),
            avatar_url: Some(format!("https://avatars.example/{id}.png")),
        }
    }

    #[test]
    fn resolver_maps_login_to_durable_user_id() {
        let resolver = StaticGithubResolver::new().with_profile(profile("mosure", 123));
        let resolved = resolver
            .resolve_login(&GithubLogin::parse("MoSuRe").expect("login parses"))
            .expect("profile resolves");
        assert_eq!(resolved.id, GithubUserId::new(123));
        assert_eq!(resolved.login.as_str(), "mosure");
    }

    #[test]
    fn renamed_login_alias_maps_to_same_id() {
        let old = GithubLogin::parse("old-login").expect("login parses");
        let resolver = StaticGithubResolver::new().with_alias(&old, profile("new-login", 123));
        let resolved = resolver.resolve_login(&old).expect("alias resolves");
        assert_eq!(resolved.id, GithubUserId::new(123));
        assert_eq!(resolved.login.as_str(), "new-login");
    }

    #[test]
    fn resolver_reports_not_found_and_rate_limit() {
        let login = GithubLogin::parse("unknown").expect("login parses");
        assert!(matches!(
            StaticGithubResolver::new().resolve_login(&login),
            Err(GithubResolveError::NotFound(_))
        ));
        assert!(matches!(
            StaticGithubResolver::new()
                .rate_limited()
                .resolve_login(&login),
            Err(GithubResolveError::RateLimited)
        ));
    }
}