gitdataai/libs/service/project/stats.rs

322 lines
13 KiB
Rust

use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Utc};
use models::ai::ai_token_usage;
use models::issues::issue;
use models::projects::{project_activity, project_members};
use models::repos::repo;
use models::rooms::{room, room_ai};
use models::users::user;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use uuid::Uuid;
/// Aggregated project statistics for dashboard display.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectStatsResponse {
pub project_id: Uuid,
pub project_name: String,
// ── Membership ─────────────────────────────────────────────
pub member_count: i64,
pub my_role: Option<String>,
// ── Repos ──────────────────────────────────────────────────
pub repo_count: i64,
// ── Issues ─────────────────────────────────────────────────
pub issue_total: i64,
pub issue_open: i64,
pub issue_closed: i64,
// ── Pull Requests ──────────────────────────────────────────
pub pr_total: i64,
pub pr_open: i64,
pub pr_merged: i64,
pub pr_closed: i64,
// ── Rooms ──────────────────────────────────────────────────
pub room_count: i64,
// ── AI usage ───────────────────────────────────────────────
pub ai_call_count: i64,
pub ai_input_tokens: i64,
pub ai_output_tokens: i64,
pub ai_cost_usd: Option<String>,
// ── Activity breakdown ─────────────────────────────────────
pub activity_last_30d: Vec<ActivityBreakdownItem>,
pub recent_activities: Vec<ProjectStatsActivityItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ActivityBreakdownItem {
pub event_type: String,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectStatsActivityItem {
pub id: i64,
pub event_type: String,
pub title: String,
pub actor_name: String,
pub actor_avatar: Option<String>,
pub created_at: DateTime<Utc>,
}
impl AppService {
/// Get aggregated project statistics for dashboard display.
pub async fn project_stats(
&self,
ctx: &Session,
project_name: String,
) -> Result<ProjectStatsResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
self.check_project_access(project.id, user_uid).await?;
// ── Member count + role ────────────────────────────────
let member_count = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.count(&self.db)
.await?;
let my_role = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(user_uid))
.one(&self.db)
.await?
.and_then(|m| m.scope_role().ok());
// ── Repo count ──────────────────────────────────────────
let repo_count = repo::Entity::find()
.filter(repo::Column::Project.eq(project.id))
.count(&self.db)
.await?;
// ── Issue counts ────────────────────────────────────────
let issue_total = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.count(&self.db)
.await?;
let issue_open = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.filter(issue::Column::State.eq("open"))
.count(&self.db)
.await?;
let issue_closed = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.filter(issue::Column::State.eq("closed"))
.count(&self.db)
.await?;
// ── PR counts ───────────────────────────────────────────
// Pull requests are linked through repos under this project.
let project_repo_ids: Vec<Uuid> = repo::Entity::find()
.filter(repo::Column::Project.eq(project.id))
.all(&self.db)
.await?
.iter()
.map(|r| r.id)
.collect();
let (pr_total, pr_open, pr_merged, pr_closed) = if project_repo_ids.is_empty() {
(0i64, 0i64, 0i64, 0i64)
} else {
use models::pull_request::pull_request as pr;
let pr_total = pr::Entity::find()
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
.count(&self.db)
.await?;
let pr_open = pr::Entity::find()
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
.filter(pr::Column::Status.eq("open"))
.count(&self.db)
.await?;
let pr_merged = pr::Entity::find()
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
.filter(pr::Column::Status.eq("merged"))
.count(&self.db)
.await?;
let pr_closed = pr::Entity::find()
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
.filter(pr::Column::Status.eq("closed"))
.count(&self.db)
.await?;
(
pr_total as i64,
pr_open as i64,
pr_merged as i64,
pr_closed as i64,
)
};
// ── Room count ──────────────────────────────────────────
let room_count = room::Entity::find()
.filter(room::Column::Project.eq(project.id))
.count(&self.db)
.await?;
// ── AI call count ───────────────────────────────────────
// Sum call_count from all room_ai configs under this project's rooms.
let project_room_ids: Vec<Uuid> = room::Entity::find()
.filter(room::Column::Project.eq(project.id))
.all(&self.db)
.await?
.iter()
.map(|r| r.id)
.collect();
let ai_call_count = if project_room_ids.is_empty() {
0i64
} else {
let room_ai_configs = room_ai::Entity::find()
.filter(room_ai::Column::Room.is_in(project_room_ids.clone()))
.all(&self.db)
.await?;
room_ai_configs.iter().map(|c| c.call_count).sum::<i64>()
};
// ── AI token usage ──────────────────────────────────────
// Sum token usage from ai_token_usage records for conversations in this project.
let ai_conversations = models::ai::ai_conversation::Entity::find()
.filter(models::ai::ai_conversation::Column::ProjectId.eq(project.id))
.all(&self.db)
.await?;
let conv_ids: Vec<Uuid> = ai_conversations.iter().map(|c| c.id).collect();
let (ai_input_tokens, ai_output_tokens, ai_cost_usd) = if conv_ids.is_empty() {
(0i64, 0i64, None)
} else {
// Token usage may also be recorded without conversation_id (room AI)
// Query by user_id + project's members too, but that's too broad.
// Instead, aggregate by conversation_id AND room-AI records.
let token_records = ai_token_usage::Entity::find()
.filter(ai_token_usage::Column::ConversationId.is_in(conv_ids.clone()))
.all(&self.db)
.await?;
let input_sum = token_records
.iter()
.map(|t| t.input_tokens as i64)
.sum::<i64>();
let output_sum = token_records
.iter()
.map(|t| t.output_tokens as i64)
.sum::<i64>();
let cost_sum: rust_decimal::Decimal =
token_records.iter().filter_map(|t| t.cost_usd).sum();
let cost_str = if cost_sum != rust_decimal::Decimal::ZERO {
Some(cost_sum.to_string())
} else {
None
};
(input_sum, output_sum, cost_str)
};
// ── Activity breakdown last 30 days ─────────────────────
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
let activity_rows = project_activity::Entity::find()
.filter(project_activity::Column::Project.eq(project.id))
.filter(project_activity::Column::CreatedAt.gte(thirty_days_ago))
.filter(project_activity::Column::IsPrivate.eq(false))
.all(&self.db)
.await?;
// Group by event_type
let mut breakdown_map: std::collections::HashMap<String, i64> =
std::collections::HashMap::new();
for a in &activity_rows {
*breakdown_map.entry(a.event_type.clone()).or_insert(0) += 1;
}
let activity_last_30d: Vec<ActivityBreakdownItem> = breakdown_map
.into_iter()
.map(|(event_type, count)| ActivityBreakdownItem { event_type, count })
.collect();
// ── Recent activities (top 10) ──────────────────────────
let recent = project_activity::Entity::find()
.filter(project_activity::Column::Project.eq(project.id))
.filter(project_activity::Column::IsPrivate.eq(false))
.order_by_desc(project_activity::Column::CreatedAt)
.limit(10)
.all(&self.db)
.await?;
// Enrich with actor info
let actor_ids: Vec<Uuid> = recent.iter().map(|a| a.actor).collect();
let actor_map: std::collections::HashMap<Uuid, (String, Option<String>)> =
if actor_ids.is_empty() {
std::collections::HashMap::new()
} else {
let users = user::Entity::find()
.filter(user::Column::Uid.is_in(actor_ids))
.all(&self.db)
.await?;
users
.into_iter()
.map(|u| {
(
u.uid,
(
u.display_name.or(Some(u.username)).unwrap_or_default(),
u.avatar_url,
),
)
})
.collect()
};
let recent_activities: Vec<ProjectStatsActivityItem> = recent
.into_iter()
.map(|a| {
let (actor_name, actor_avatar) = actor_map
.get(&a.actor)
.cloned()
.unwrap_or_else(|| ("Unknown".to_string(), None));
ProjectStatsActivityItem {
id: a.id,
event_type: a.event_type,
title: a.title,
actor_name,
actor_avatar,
created_at: a.created_at,
}
})
.collect();
Ok(ProjectStatsResponse {
project_id: project.id,
project_name: project.name,
member_count: member_count as i64,
my_role: my_role.map(|r| r.to_string()),
repo_count: repo_count as i64,
issue_total: issue_total as i64,
issue_open: issue_open as i64,
issue_closed: issue_closed as i64,
pr_total,
pr_open,
pr_merged,
pr_closed,
room_count: room_count as i64,
ai_call_count,
ai_input_tokens,
ai_output_tokens,
ai_cost_usd,
activity_last_30d,
recent_activities,
})
}
}