44c6130
ZhenYi
refactor: update database handling and introduce BlobStore trait for improved abstraction and flexibility
44c6130
use std::collections::HashMap;
use crate::rpcgit::commit::types::TreeEntryMode;
use crate::rpcgit::error::BabyError;
use models::git::git_languages;
use models::repo::repos;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, Unchanged,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::context::facade::GitContext;
use crate::service::GitService;
use crate::service::cache::{self, ANALYSIS_TTL};
use crate::share::errors::{GitError, map_baby};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LanguageStatDto {
pub language: String,
pub percentage_bp: i32,
pub size: i64,
pub is_primary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ContributorStatDto {
pub name: String,
pub email: String,
pub commits: i64,
pub additions: i64,
pub deletions: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CodeActivityAuthorDto {
pub name: String,
pub email: String,
pub commits: i64,
pub additions: i64,
pub deletions: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CodeActivityDto {
pub authors: Vec<CodeActivityAuthorDto>,
pub total_commits: i64,
pub total_additions: i64,
pub total_deletions: i64,
}
pub fn ext_to_language(name: &str) -> Option<&'static str> {
let base = name.rsplit('/').next().unwrap_or(name);
match base {
"Dockerfile" => return Some("Dockerfile"),
"Makefile" => return Some("Makefile"),
"Gemfile" => return Some("Ruby"),
_ => {}
}
let ext = base.rsplit('.').next()?.to_ascii_lowercase();
let lang = match ext.as_str() {
"rs" => "Rust",
"go" => "Go",
"py" => "Python",
"js" => "JavaScript",
"ts" => "TypeScript",
"tsx" => "TypeScript",
"jsx" => "JavaScript",
"java" => "Java",
"c" | "h" => "C",
"cpp" | "cc" | "hpp" => "C++",
"cs" => "C#",
"php" => "PHP",
"rb" => "Ruby",
"swift" => "Swift",
"kt" | "kts" => "Kotlin",
"scala" => "Scala",
"md" | "markdown" => "Markdown",
"yaml" | "yml" => "YAML",
"json" => "JSON",
"toml" => "TOML",
"xml" => "XML",
"html" | "htm" => "HTML",
"css" => "CSS",
"scss" | "sass" => "SCSS",
"sql" => "SQL",
"sh" | "bash" | "zsh" => "Shell",
"dockerfile" => "Dockerfile",
"proto" => "Protocol Buffers",
"vue" => "Vue",
"lua" => "Lua",
"dart" => "Dart",
_ => return None,
};
Some(lang)
}
impl GitService {
/// Recompute language breakdown from the tree at HEAD and persist into
/// `git_language` (percentage in basis points) + `repos.primary_language`.
pub async fn analysis_languages(
&self,
ctx: &GitContext,
) -> Result<Vec<LanguageStatDto>, GitError> {
if ctx.repo_uninitialized() {
return Ok(Vec::new());
}
let baby = self.baby(&ctx);
let Some(revision) =
resolve_analysis_revision(&baby, None, &ctx.model.default_branch).await?
else {
let db = self.db_write();
git_languages::Entity::delete_many()
.filter(git_languages::Column::RepoId.eq(ctx.model.id))
.exec(db)
.await?;
let am = repos::ActiveModel {
id: Unchanged(ctx.model.id),
primary_language: Set(None),
updated_at: Set(OffsetDateTime::now_utc()),
..Default::default()
};
am.update(db).await?;
return Ok(Vec::new());
};
let key = format!("repo.{}.analysis_languages.v1.{}", ctx.model.id, revision);
cache::cached_fetch(&ctx.cache, &key, ANALYSIS_TTL, || async {
let langs = language_stats(&baby, &revision).await?;
let total: i64 = langs.values().sum();
let mut rows: Vec<(String, i64)> = langs.into_iter().collect();
rows.sort_by_key(|(_, size)| std::cmp::Reverse(*size));
let db = self.db_write();
git_languages::Entity::delete_many()
.filter(git_languages::Column::RepoId.eq(ctx.model.id))
.exec(db)
.await?;
let now = OffsetDateTime::now_utc();
let primary = rows.first().map(|(name, _)| name.clone());
for (language, size) in &rows {
let bp = if total > 0 {
((*size as f64 / total as f64) * 10_000.0).round() as i32
} else {
0
};
git_languages::ActiveModel {
id: Set(Uuid::now_v7()),
repo_id: Set(ctx.model.id),
language: Set(language.clone()),
percentage_bp: Set(bp),
size: Set(*size),
created_at: Set(now),
}
.insert(db)
.await?;
}
let am = repos::ActiveModel {
id: Unchanged(ctx.model.id),
primary_language: Set(primary),
updated_at: Set(now),
..Default::default()
};
am.update(db).await?;
let primary_name = rows.first().map(|(n, _)| n.clone());
Ok(rows
.into_iter()
.map(|(language, size)| {
let is_primary = primary_name.as_deref() == Some(language.as_str());
LanguageStatDto {
language,
percentage_bp: if total > 0 {
((size as f64 / total as f64) * 10_000.0).round() as i32
} else {
0
},
size,
is_primary,
}
})
.collect())
})
.await
}
pub async fn analysis_languages_read(
&self,
ctx: &GitContext,
) -> Result<Vec<LanguageStatDto>, GitError> {
let rows = git_languages::Entity::find()
.filter(git_languages::Column::RepoId.eq(ctx.model.id))
.order_by_desc(git_languages::Column::Size)
.all(self.db_read())
.await?;
Ok(rows
.into_iter()
.enumerate()
.map(|(i, r)| LanguageStatDto {
language: r.language,
percentage_bp: r.percentage_bp,
size: r.size,
is_primary: i == 0,
})
.collect())
}
pub async fn analysis_contributors(
&self,
ctx: &GitContext,
) -> Result<Vec<ContributorStatDto>, GitError> {
if ctx.repo_uninitialized() {
return Ok(Vec::new());
}
let baby = self.baby(&ctx);
let Some(revision) =
resolve_analysis_revision(&baby, None, &ctx.model.default_branch).await?
else {
return Ok(Vec::new());
};
let rev_oid = baby.rev_parse(&revision).await.map_err(map_baby)?;
let key = format!("repo.{}.analysis_contributors.v2.{}", ctx.model.id, rev_oid);
cache::cached_fetch(&ctx.cache, &key, ANALYSIS_TTL, || async {
let entries = baby.commit_short_stats(&revision).await.map_err(map_baby)?;
let mut map: HashMap<(String, String), (i64, i64, i64)> = HashMap::new();
for e in &entries {
let slot = map
.entry((e.author_name.clone(), e.author_email.clone()))
.or_default();
slot.0 += 1;
slot.1 += e.additions as i64;
slot.2 += e.deletions as i64;
}
let mut out: Vec<ContributorStatDto> = map
.into_iter()
.map(
|((name, email), (commits, additions, deletions))| ContributorStatDto {
name,
email,
commits,
additions,
deletions,
},
)
.collect();
out.sort_by_key(|b| std::cmp::Reverse(b.commits));
Ok(out)
})
.await
}
pub async fn analysis_code_activity(
&self,
ctx: &GitContext,
branch: Option<String>,
) -> Result<CodeActivityDto, GitError> {
let baby = self.baby(&ctx);
let Some(revision) =
resolve_analysis_revision(&baby, branch.as_deref(), &ctx.model.default_branch).await?
else {
return Ok(CodeActivityDto {
authors: Vec::new(),
total_commits: 0,
total_additions: 0,
total_deletions: 0,
});
};
let rev_oid = baby.rev_parse(&revision).await.map_err(map_baby)?;
let key = format!(
"repo.{}.analysis_code_activity.v2.{}",
ctx.model.id, rev_oid
);
cache::cached_fetch(&ctx.cache, &key, ANALYSIS_TTL, || async {
let entries = baby.commit_short_stats(&revision).await.map_err(map_baby)?;
let mut authors: Vec<CodeActivityAuthorDto> = Vec::new();
for e in &entries {
match authors.iter_mut().find(|a| a.email == e.author_email) {
Some(a) => {
a.commits += 1;
a.additions += e.additions as i64;
a.deletions += e.deletions as i64;
}
None => authors.push(CodeActivityAuthorDto {
name: e.author_name.clone(),
email: e.author_email.clone(),
commits: 1,
additions: e.additions as i64,
deletions: e.deletions as i64,
}),
}
}
authors.sort_by_key(|b| std::cmp::Reverse(b.commits));
Ok(CodeActivityDto {
total_commits: entries.len() as i64,
total_additions: entries.iter().map(|e| e.additions as i64).sum(),
total_deletions: entries.iter().map(|e| e.deletions as i64).sum(),
authors,
})
})
.await
}
}
async fn resolve_analysis_revision(
baby: &crate::rpcgit::GitBaby,
preferred: Option<&str>,
default_branch: &str,
) -> Result<Option<String>, GitError> {
let mut candidates: Vec<String> = Vec::new();
if let Some(b) = preferred
&& !b.trim().is_empty()
{
candidates.push(b.to_string());
}
candidates.push("HEAD".to_string());
let db = default_branch.trim();
if !db.is_empty() {
let head = format!("refs/heads/{db}");
if !candidates.contains(&head) {
candidates.push(head);
}
}
for cand in candidates {
match baby.rev_parse(&cand).await {
Ok(oid) => return Ok(Some(oid.to_string())),
Err(BabyError::RevisionNotFound(_)) => continue,
Err(e) => return Err(map_baby(e)),
}
}
let branches = baby
.list_branches(crate::rpcgit::branch::ListBranchesOptions::default())
.await
.map_err(map_baby)?;
Ok(branches.into_iter().next().map(|b| b.sha.to_string()))
}
pub(crate) async fn language_stats(
baby: &crate::rpcgit::GitBaby,
revision: &str,
) -> Result<HashMap<String, i64>, GitError> {
let entries = baby
.walk_tree(
revision,
std::path::Path::new(""),
crate::rpcgit::tree::types::WalkTreeOptions {
recurse: true,
show_trees: false,
},
)
.await
.map_err(map_baby)?;
let mut map: HashMap<String, i64> = HashMap::new();
for e in entries {
if (e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec)
&& let Some(lang) = ext_to_language(&e.name)
{
*map.entry(lang.to_string()).or_default() += e.size.unwrap_or(0) as i64;
}
}
Ok(map)
}