89109d2
use std::path::PathBuf;
use crate::rpcgit::commit::types::{CommitInfo, CommitStats, ListCommitsOptions, TreeEntry};
use models::git::git_commit_status;
use models::git::git_commit_status::CommitStatusState;
use models::git::git_commits;
use models::users::user_emails;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, 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<PathBuf>,
#[serde(default)]
pub max_count: Option<u32>,
#[serde(default)]
pub skip: Option<u32>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[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<String>,
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<CommitInfo> 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<CommitDto>,
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<CommitStatusDtoState> 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<CommitStatusState> 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<String>,
#[serde(default)]
pub target_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct CommitStatusDto {
pub state: CommitStatusDtoState,
pub context: String,
pub description: Option<String>,
pub target_url: Option<String>,
pub creator_id: i64,
pub creator: Option<space::services::spaces::UserDto>,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
impl From<git_commit_status::Model> 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(db: &DatabaseConnection, email: &str) -> Result<Option<i64>, GitError> {
let row = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(email.to_lowercase()))
.one(db)
.await?;
Ok(row.map(|r| r.user_id))
}
impl GitService {
pub async fn get_commit(
&self,
space_name: String,
repo_name: String,
revision: String,
) -> Result<CommitInfo, GitError> {
let ctx = self.context(space_name, repo_name).await?;
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,
space_name: String,
repo_name: String,
cmd: ListCommitsCmd,
viewer: Option<i64>,
) -> Result<CommitsPageDto, GitError> {
let ctx = self.context(space_name, repo_name).await?;
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<CommitDto>, 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::<Vec<_>>();
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<i64>) -> 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,
space_name: String,
repo_name: String,
revision: String,
path: Option<PathBuf>,
) -> Result<CommitStats, GitError> {
let ctx = self.context(space_name, repo_name).await?;
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,
space_name: String,
repo_name: String,
revision: String,
path: PathBuf,
) -> Result<Vec<TreeEntry>, GitError> {
let ctx = self.context(space_name, repo_name).await?;
let baby = self.baby(&ctx);
baby.get_tree_entries(&revision, &path)
.await
.map_err(map_baby)
}
pub async fn rev_parse(
&self,
space_name: String,
repo_name: String,
revision: String,
) -> Result<String, GitError> {
let ctx = self.context(space_name, repo_name).await?;
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,
space_name: String,
repo_name: String,
ancestor: String,
descendant: String,
) -> Result<bool, GitError> {
let ctx = self.context(space_name, repo_name).await?;
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<git_commits::Model, GitError> {
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.db_read(), &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,
space_name: String,
repo_name: String,
creator: i64,
cmd: SetCommitStatusCmd,
) -> Result<CommitStatusDto, GitError> {
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()));
}
let ctx = self.context(space_name, repo_name).await?;
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,
space_name: String,
repo_name: String,
sha: String,
) -> Result<Vec<CommitStatusDto>, GitError> {
if sha.trim().is_empty() {
return Err(GitError::InvalidArgument("empty sha".to_string()));
}
let ctx = self.context(space_name, repo_name).await?;
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<CommitStatusDto> = rows.into_iter().map(CommitStatusDto::from).collect();
let uids: Vec<i64> = 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)
}
}