use crate::AppService; use crate::error::AppError; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; use session::Session; use std::collections::HashMap; #[derive(Debug, Clone, Deserialize)] pub struct ContributorsQuery { #[serde(default = "default_limit")] pub limit: usize, #[serde(default)] pub ref_name: Option, } fn default_limit() -> usize { 100 } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ContributorStats { pub name: String, pub email: String, pub commits: usize, pub first_commit_at: Option, pub last_commit_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ContributorsResponse { pub total: usize, pub contributors: Vec, } struct ContributorEntry { name: String, email: String, commits: usize, first_commit_at: Option, last_commit_at: Option, } impl AppService { pub async fn git_contributors( &self, namespace: String, repo_name: String, query: ContributorsQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let cache_key = format!( "git:contributors:{}:{}:{:?}:{}", namespace, repo_name, query.ref_name, query.limit, ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo_clone = repo.clone(); let ref_name_clone = query.ref_name.clone(); let commits = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo_clone)?; domain.commit_log(ref_name_clone.as_deref(), 0, query.limit) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; let mut author_map: HashMap = HashMap::new(); for commit in commits { let author = commit.author; let time = author.time_secs; // Use email as primary key (case-insensitive) for deduplication. // If the same person uses multiple emails, they appear as separate contributors — // this is the best we can do without an external identity service. let key = author.email.to_lowercase(); let entry = author_map.entry(key).or_insert_with(|| ContributorEntry { name: author.name.clone(), email: author.email.clone(), commits: 0, first_commit_at: None, last_commit_at: None, }); entry.commits += 1; entry.first_commit_at = Some(entry.first_commit_at.map(|f| f.min(time)).unwrap_or(time)); entry.last_commit_at = Some(entry.last_commit_at.map(|l| l.max(time)).unwrap_or(time)); } let mut contributors: Vec = author_map .into_values() .map(|e| ContributorStats { name: e.name, email: e.email, commits: e.commits, first_commit_at: e.first_commit_at, last_commit_at: e.last_commit_at, }) .collect(); contributors.sort_by(|a, b| b.commits.cmp(&a.commits)); let total = contributors.len(); let response = ContributorsResponse { total, contributors, }; if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 60 * 60, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } }