use std::path::PathBuf; use crate::rpcgit::commit::types::{CommitInfo, CommitStats, ListCommitsOptions, TreeEntry}; use models::git::git_commit_status::CommitStatusState; use models::git::{git_commit_status, git_commits}; 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, IMMUTABLE_TTL}; use crate::share::errors::{GitError, map_baby}; fn default_revision() -> String { "HEAD".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct ListCommitsCmd { #[serde(default = "default_revision")] pub revision: String, #[serde(default)] pub path: Option, #[serde(default)] pub max_count: Option, #[serde(default)] pub skip: Option, #[serde(default)] pub since: Option, #[serde(default)] pub until: Option, #[serde(default)] pub first_parent: bool, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitDto { pub id: String, pub tree_id: String, pub parents: Vec, pub author_name: String, pub author_email: String, pub author_time: i64, pub committer_name: String, pub committer_email: String, pub committer_time: i64, pub message: String, } impl From for CommitDto { fn from(c: CommitInfo) -> Self { Self { id: c.id.to_string(), tree_id: c.tree_id.to_string(), parents: c.parents.iter().map(|p| p.to_string()).collect(), author_name: c.author.name, author_email: c.author.email, author_time: c.author.time.unix_timestamp(), committer_name: c.committer.name, committer_email: c.committer.email, committer_time: c.committer.time.unix_timestamp(), message: c.message, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitsPageDto { pub commits: Vec, pub total: u64, pub skip: u32, pub limit: u32, pub has_more: bool, pub diff_view_style: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum CommitStatusDtoState { Pending, Success, Failure, Error, } impl From for CommitStatusState { fn from(s: CommitStatusDtoState) -> Self { match s { CommitStatusDtoState::Pending => CommitStatusState::Pending, CommitStatusDtoState::Success => CommitStatusState::Success, CommitStatusDtoState::Failure => CommitStatusState::Failure, CommitStatusDtoState::Error => CommitStatusState::Error, } } } impl From for CommitStatusDtoState { fn from(s: CommitStatusState) -> Self { match s { CommitStatusState::Pending => CommitStatusDtoState::Pending, CommitStatusState::Success => CommitStatusDtoState::Success, CommitStatusState::Failure => CommitStatusDtoState::Failure, CommitStatusState::Error => CommitStatusDtoState::Error, } } } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct SetCommitStatusCmd { pub sha: String, pub state: CommitStatusDtoState, pub context: String, #[serde(default)] pub description: Option, #[serde(default)] pub target_url: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitStatusDto { pub state: CommitStatusDtoState, pub context: String, pub description: Option, pub target_url: Option, pub creator_id: i64, pub creator: Option, pub created_at: OffsetDateTime, pub updated_at: Option, } impl From for CommitStatusDto { fn from(m: git_commit_status::Model) -> Self { Self { state: m.state.into(), context: m.context, description: m.description, target_url: m.target_url, creator_id: m.creator_id, creator: None, created_at: m.created_at, updated_at: m.updated_at, } } } async fn uid_for_email(state: &export::AppState, email: &str) -> Result, GitError> { Ok(export::users::find_by_email(state, email) .await? .map(|user| user.user_id)) } impl GitService { pub async fn get_commit( &self, ctx: &GitContext, revision: String, ) -> Result { let baby = self.baby(&ctx); let rev_oid = baby.rev_parse(&revision).await.map_err(map_baby)?; let key = format!("repo.{}.commit.{}", ctx.model.id, rev_oid); cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async { baby.get_commit(&revision).await.map_err(map_baby) }) .await } pub async fn list_commits( &self, ctx: &GitContext, cmd: ListCommitsCmd, viewer: Option, ) -> Result { let baby = self.baby(&ctx); let limit = cmd.max_count.unwrap_or(50); let skip = cmd.skip.unwrap_or(0); let rev_key = if let Some((after, before)) = cmd.revision.split_once("..") { let a = baby.rev_parse(after).await.map_err(map_baby)?; let b = baby.rev_parse(before).await.map_err(map_baby)?; format!("{a}..{b}") } else { baby.rev_parse(&cmd.revision) .await .map_err(map_baby)? .to_string() }; let rev_oid = rev_key.clone(); let path_str = cmd .path .as_ref() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); let page_cache_key = format!( "repo.{}.commits_page.{}:{}:{}:{}:{}:{}:{}", ctx.model.id, rev_oid, skip, limit, path_str, cmd.since.as_deref().unwrap_or(""), cmd.until.as_deref().unwrap_or(""), cmd.first_parent, ); let opts = ListCommitsOptions { revision: cmd.revision, path: cmd.path, max_count: Some(limit), skip: Some(skip), since: cmd.since, until: cmd.until, first_parent: cmd.first_parent, }; let (commits, total): (Vec, u64) = cache::cached_fetch(&ctx.cache, &page_cache_key, IMMUTABLE_TTL, || async { let total = baby.count_commits(opts.clone()).await.map_err(map_baby)?; let commits = baby .list_commits(opts) .await .map_err(map_baby)? .into_iter() .map(CommitDto::from) .collect::>(); Ok((commits, total)) }) .await?; let has_more = (skip as u64 + commits.len() as u64) < total; let diff_view_style = self.user_diff_view_style(viewer).await; Ok(CommitsPageDto { commits, total, skip, limit, has_more, diff_view_style, }) } pub async fn user_diff_view_style(&self, viewer: Option) -> String { if let Some(uid) = viewer && let Ok(Some(row)) = models::users::user_preferences::Entity::find_by_id(uid) .one(self.db_read()) .await && let Some(style) = row.diff_view_style && !style.is_empty() { return style; } "unified".to_string() } pub async fn commit_stats( &self, ctx: &GitContext, revision: String, path: Option, ) -> Result { let baby = self.baby(&ctx); let rev_oid = baby.rev_parse(&revision).await.map_err(map_baby)?; let path_str = path .as_ref() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); let key = format!( "repo.{}.commit_stats.{}.{}", ctx.model.id, rev_oid, path_str ); cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async { baby.commit_stats(&revision, path.as_deref()) .await .map_err(map_baby) }) .await } pub async fn get_tree_entries( &self, ctx: &GitContext, revision: String, path: PathBuf, ) -> Result, GitError> { let baby = self.baby(&ctx); baby.get_tree_entries(&revision, &path) .await .map_err(map_baby) } pub async fn rev_parse(&self, ctx: &GitContext, revision: String) -> Result { let baby = self.baby(&ctx); baby.rev_parse(&revision) .await .map(|oid| oid.to_string()) .map_err(map_baby) } pub async fn is_ancestor( &self, ctx: &GitContext, ancestor: String, descendant: String, ) -> Result { let baby = self.baby(&ctx); baby.is_ancestor(&ancestor, &descendant) .await .map_err(map_baby) } /// Upsert the DB mirror of a commit. `author_id` is resolved /// best-effort from the commit author email. pub async fn sync_commit( &self, ctx: &GitContext, revision: &str, ) -> Result { let baby = self.baby(ctx); let info = baby.get_commit(revision).await.map_err(map_baby)?; let sha = info.id.to_string(); let existing = git_commits::Entity::find_by_id((ctx.model.id, sha.clone())) .one(self.db_read()) .await?; let author_id = uid_for_email(&self.state, &info.author.email).await?; let now = OffsetDateTime::now_utc(); let row = match existing { Some(cur) => { let am = git_commits::ActiveModel { repo_id: Unchanged(cur.repo_id), sha: Unchanged(cur.sha), message: Set(info.message.clone()), author_id: Set(author_id), author_name: Set(info.author.name.clone()), author_email: Set(info.author.email.clone()), authored_at: Set(info.author.time), committer_name: Set(info.committer.name.clone()), committer_email: Set(info.committer.email.clone()), committed_at: Set(info.committer.time), ..Default::default() }; am.update(self.db_write()).await? } None => { let am = git_commits::ActiveModel { repo_id: Set(ctx.model.id), sha: Set(sha.clone()), message: Set(info.message.clone()), author_id: Set(author_id), author_name: Set(info.author.name.clone()), author_email: Set(info.author.email.clone()), authored_at: Set(info.author.time), committer_name: Set(info.committer.name.clone()), committer_email: Set(info.committer.email.clone()), committed_at: Set(info.committer.time), created_at: Set(now), }; am.insert(self.db_write()).await? } }; Ok(row) } pub async fn set_commit_status( &self, ctx: &GitContext, creator: i64, cmd: SetCommitStatusCmd, ) -> Result { if cmd.sha.trim().is_empty() { return Err(GitError::InvalidArgument("empty sha".to_string())); } if cmd.context.trim().is_empty() || cmd.context.chars().count() > 255 { return Err(GitError::InvalidArgument("invalid context".to_string())); } self.baby(&ctx) .rev_parse(cmd.sha.trim()) .await .map_err(map_baby)?; self.sync_commit(&ctx, cmd.sha.trim()).await?; let existing = git_commit_status::Entity::find() .filter(git_commit_status::Column::RepoId.eq(ctx.model.id)) .filter(git_commit_status::Column::Sha.eq(cmd.sha.trim())) .filter(git_commit_status::Column::Context.eq(cmd.context.trim())) .one(self.db_write()) .await?; let now = OffsetDateTime::now_utc(); let model = match existing { Some(cur) => { let am = git_commit_status::ActiveModel { id: Unchanged(cur.id), state: Set(cmd.state.into()), description: Set(cmd.description.clone()), target_url: Set(cmd.target_url.clone()), updated_at: Set(Some(now)), ..Default::default() }; am.update(self.db_write()).await? } None => { let am = git_commit_status::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(ctx.model.id), sha: Set(cmd.sha.trim().to_string()), state: Set(cmd.state.into()), context: Set(cmd.context.trim().to_string()), description: Set(cmd.description.clone()), target_url: Set(cmd.target_url.clone()), creator_id: Set(creator), created_at: Set(now), updated_at: Set(None), }; am.insert(self.db_write()).await? } }; Ok(CommitStatusDto::from(model)) } pub async fn list_commit_statuses( &self, ctx: &GitContext, sha: String, ) -> Result, GitError> { if sha.trim().is_empty() { return Err(GitError::InvalidArgument("empty sha".to_string())); } let rows = git_commit_status::Entity::find() .filter(git_commit_status::Column::RepoId.eq(ctx.model.id)) .filter(git_commit_status::Column::Sha.eq(sha.trim())) .order_by_asc(git_commit_status::Column::CreatedAt) .all(self.db_read()) .await?; let mut out: Vec = rows.into_iter().map(CommitStatusDto::from).collect(); let uids: Vec = out.iter().map(|c| c.creator_id).collect(); let map = self.user_dto_map(&uids).await?; for c in out.iter_mut() { c.creator = map.get(&c.creator_id).cloned(); } Ok(out) } }