use crate::context::facade::GitContext; use export::pagination::Page; use models::ai::code_comment; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, }; use serde::{Deserialize, Serialize}; use space::services::spaces::UserDto; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::share::errors::GitError; const MAX_BODY_LEN: usize = 65_536; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CodeCommentDto { pub id: Uuid, pub repo_id: Uuid, pub path: String, pub line: i32, pub commit_sha: String, pub author: Option, pub parent_id: Option, pub body: String, pub resolved: bool, pub resolved_by: Option, /// 该评论锚定的提交是否仍是当前分支提示(否则视为 outdated)。 pub outdated: bool, pub created_at: OffsetDateTime, pub updated_at: Option, } pub type CodeCommentPage = Page; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateCodeCommentCmd { pub path: String, pub line: i32, /// 锚定的提交;缺省使用该 revision 解析出的提交。 #[serde(default)] pub commit_sha: Option, #[serde(default)] pub revision: Option, #[serde(default)] pub parent_id: Option, pub body: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateCodeCommentCmd { pub body: String, } #[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] pub struct ListCodeCommentsQuery { #[serde(default)] pub path: Option, #[serde(default)] pub revision: Option, #[serde(default)] pub resolved: Option, #[serde(default)] pub limit: Option, #[serde(default)] pub offset: Option, } impl super::GitService { pub async fn list_code_comments( &self, space_name: String, repo_name: String, query: ListCodeCommentsQuery, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let limit = query.limit.unwrap_or(50).clamp(1, 200); let offset = query.offset.unwrap_or(0); let mut select = code_comment::Entity::find().filter(code_comment::Column::RepoId.eq(ctx.model.id)); if let Some(path) = query .path .as_deref() .filter(|value| !value.trim().is_empty()) { select = select.filter(code_comment::Column::Path.eq(path.trim())); } if let Some(revision) = query .revision .as_deref() .filter(|value| !value.trim().is_empty()) { let sha = self .baby(&ctx) .rev_parse(revision) .await .map_err(crate::share::errors::map_baby)? .to_string(); select = select.filter(code_comment::Column::CommitSha.eq(sha)); } if let Some(resolved) = query.resolved { if resolved { select = select.filter(code_comment::Column::ResolvedAt.is_not_null()); } else { select = select.filter(code_comment::Column::ResolvedAt.is_null()); } } let total = select.clone().count(self.db_read()).await? as i64; let rows = select .order_by_asc(code_comment::Column::Path) .order_by_asc(code_comment::Column::Line) .order_by_asc(code_comment::Column::CreatedAt) .offset(offset) .limit(limit) .all(self.db_read()) .await?; let items = self.code_comment_dtos(&ctx, rows).await?; Ok(Page { items, total, limit, offset, }) } pub async fn create_code_comment( &self, space_name: String, repo_name: String, author: i64, cmd: CreateCodeCommentCmd, ) -> Result { if cmd.path.trim().is_empty() { return Err(GitError::InvalidArgument("empty path".to_string())); } if cmd.line <= 0 { return Err(GitError::InvalidArgument( "line must be positive".to_string(), )); } if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_BODY_LEN { return Err(GitError::InvalidArgument("invalid body".to_string())); } let ctx = self.context(space_name, repo_name).await?; let anchor = match cmd .commit_sha .as_deref() .filter(|value| !value.trim().is_empty()) { Some(sha) => sha.trim().to_string(), None => { let revision = cmd.revision.as_deref().unwrap_or("HEAD"); self.baby(&ctx) .rev_parse(revision) .await .map_err(crate::share::errors::map_baby)? .to_string() } }; if let Some(parent_id) = cmd.parent_id { let parent = code_comment::Entity::find_by_id(parent_id) .filter(code_comment::Column::RepoId.eq(ctx.model.id)) .one(self.db_read()) .await?; if parent.is_none() { return Err(GitError::InvalidArgument( "parent comment not found".to_string(), )); } } let now = OffsetDateTime::now_utc(); let row = code_comment::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(ctx.model.id), path: Set(cmd.path.trim().to_string()), line: Set(cmd.line), commit_sha: Set(anchor), author_id: Set(author), parent_id: Set(cmd.parent_id), body: Set(cmd.body), resolved_at: Set(None), resolved_by: Set(None), created_at: Set(now), updated_at: Set(None), } .insert(self.db_write()) .await?; let mut out = self.code_comment_dtos(&ctx, vec![row]).await?; out.pop().ok_or(GitError::NotFound) } pub async fn update_code_comment( &self, space_name: String, repo_name: String, comment_id: Uuid, actor: i64, admin: bool, cmd: UpdateCodeCommentCmd, ) -> Result { if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_BODY_LEN { return Err(GitError::InvalidArgument("invalid body".to_string())); } let ctx = self.context(space_name, repo_name).await?; let row = self.find_code_comment(ctx.model.id, comment_id).await?; if row.author_id != actor && !admin { return Err(GitError::Forbidden); } let mut model: code_comment::ActiveModel = row.into(); model.body = Set(cmd.body); model.updated_at = Set(Some(OffsetDateTime::now_utc())); let updated = model.update(self.db_write()).await?; let mut out = self.code_comment_dtos(&ctx, vec![updated]).await?; out.pop().ok_or(GitError::NotFound) } pub async fn delete_code_comment( &self, space_name: String, repo_name: String, comment_id: Uuid, actor: i64, admin: bool, ) -> Result<(), GitError> { let ctx = self.context(space_name, repo_name).await?; let row = self.find_code_comment(ctx.model.id, comment_id).await?; if row.author_id != actor && !admin { return Err(GitError::Forbidden); } // 连同其回复一并删除,与既有评论模型一致。 use sea_orm::Condition; code_comment::Entity::delete_many() .filter( Condition::any() .add(code_comment::Column::Id.eq(comment_id)) .add(code_comment::Column::ParentId.eq(comment_id)), ) .exec(self.db_write()) .await?; Ok(()) } pub async fn set_code_comment_resolved( &self, space_name: String, repo_name: String, comment_id: Uuid, actor: i64, resolved: bool, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let row = self.find_code_comment(ctx.model.id, comment_id).await?; let mut model: code_comment::ActiveModel = row.into(); model.resolved_at = Set(if resolved { Some(OffsetDateTime::now_utc()) } else { None }); model.resolved_by = Set(if resolved { Some(actor) } else { None }); let updated = model.update(self.db_write()).await?; let mut out = self.code_comment_dtos(&ctx, vec![updated]).await?; out.pop().ok_or(GitError::NotFound) } async fn find_code_comment( &self, repo_id: Uuid, comment_id: Uuid, ) -> Result { code_comment::Entity::find_by_id(comment_id) .filter(code_comment::Column::RepoId.eq(repo_id)) .one(self.db_read()) .await? .ok_or(GitError::NotFound) } async fn code_comment_dtos( &self, ctx: &GitContext, rows: Vec, ) -> Result, GitError> { if rows.is_empty() { return Ok(Vec::new()); } let uids: Vec = rows.iter().map(|row| row.author_id).collect(); let users = self.user_dto_map(&uids).await?; // outdated 判定:锚定提交不是当前默认分支提示即视为过期。 let head = self .baby(ctx) .rev_parse(&format!("refs/heads/{}", ctx.model.default_branch)) .await .map(|oid| oid.to_string()) .ok(); Ok(rows .into_iter() .map(|row| CodeCommentDto { id: row.id, repo_id: row.repo_id, path: row.path, line: row.line, commit_sha: row.commit_sha.clone(), author: users.get(&row.author_id).cloned(), parent_id: row.parent_id, body: row.body, resolved: row.resolved_at.is_some(), resolved_by: row.resolved_by, outdated: head.as_deref().is_some_and(|head| head != row.commit_sha), created_at: row.created_at, updated_at: row.updated_at, }) .collect()) } }