use export::pagination::{Page, PageQuery}; use git::context::facade::GitContext; use models::pull_request::{pull_request_comment, pull_requests}; use sea_orm::ActiveValue::Set; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, }; use serde::{Deserialize, Serialize}; use space::services::dto::UserDto; use std::collections::HashMap; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::PullError; use crate::services::events::EVENT_COMMENTED; use crate::services::reviews; use crate::services::service::PullService; use crate::share::authz::{require_open, require_unlocked}; pub const MAX_COMMENT_LEN: usize = 65_536; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreatePullCommentCmd { pub body: String, /// 行内评论需要 path;缺省则为普通讨论评论。 #[serde(default)] pub path: Option, #[serde(default)] pub line: Option, /// left | right #[serde(default)] pub side: Option, #[serde(default)] pub commit_sha: Option, #[serde(default)] pub review_id: Option, #[serde(default)] pub parent_comment_id: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdatePullCommentCmd { pub body: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PullCommentDto { pub id: Uuid, pub pull_request_id: Uuid, pub author: Option, pub body: String, pub path: Option, pub line: Option, pub side: Option, pub commit_sha: Option, pub review_id: Option, pub parent_comment_id: Option, pub resolved: bool, pub resolved_by: Option, pub created_at: OffsetDateTime, pub updated_at: Option, } /// 线程视图:顶层评论 + 其下全部回复(无递归,便于文档生成)。 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PullCommentThreadDto { pub root: PullCommentDto, pub replies: Vec, } pub type CommentPage = Page; impl PullService { pub async fn create_comment( &self, ctx: &GitContext, pull: &pull_requests::Model, caller: i64, cmd: CreatePullCommentCmd, ) -> Result { require_open(pull)?; require_unlocked(pull)?; if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_COMMENT_LEN { return Err(PullError::BadRequest("invalid comment body".to_string())); } if !self .state .redis .quota_allow( &format!("quota:comment_create:{caller}"), self.config().quota.comment_create_per_day, 86_400, ) .await { return Err(PullError::RateLimited( "comment creation quota exceeded, retry tomorrow".to_string(), )); } let inline = cmd.path.as_deref().is_some_and(|p| !p.trim().is_empty()); let commit_sha = match (inline, cmd.commit_sha.clone()) { (false, _) => None, (true, Some(sha)) => Some(sha), (true, None) => Some(pull.head_sha.clone()), }; if !inline && cmd.line.is_some() { return Err(PullError::BadRequest("line requires a path".to_string())); } if let Some(line) = cmd.line && line <= 0 { return Err(PullError::BadRequest("line must be positive".to_string())); } if let Some(side) = cmd.side.as_deref() && side != "left" && side != "right" { return Err(PullError::BadRequest( "side must be left or right".to_string(), )); } if let Some(parent) = cmd.parent_comment_id && !pull_request_comment::Entity::find_by_id(parent) .filter(pull_request_comment::Column::PullRequestId.eq(pull.id)) .one(self.db_read()) .await? .is_some() { return Err(PullError::BadRequest( "parent comment not found".to_string(), )); } if let Some(review_id) = cmd.review_id && !models::pull_request::pull_request_review::Entity::find_by_id(review_id) .filter( models::pull_request::pull_request_review::Column::PullRequestId.eq(pull.id), ) .one(self.db_read()) .await? .is_some() { return Err(PullError::BadRequest("review not found".to_string())); } let row = pull_request_comment::ActiveModel { id: Set(Uuid::now_v7()), pull_request_id: Set(pull.id), user_id: Set(caller), review_id: Set(cmd.review_id), parent_comment_id: Set(cmd.parent_comment_id), commit_sha: Set(commit_sha.clone()), original_commit_sha: Set(commit_sha.clone()), path: Set(if inline { cmd.path.clone() } else { None }), side: Set(if inline { Some(cmd.side.clone().unwrap_or_else(|| "right".to_string())) } else { None }), line: Set(if inline { cmd.line } else { None }), original_line: Set(if inline { cmd.line } else { None }), body: Set(cmd.body.clone()), resolved_at: Set(None), resolved_by: Set(None), created_at: Set(OffsetDateTime::now_utc()), updated_at: Set(None), } .insert(self.db_write()) .await?; self.record_event(pull, Some(caller), EVENT_COMMENTED, commit_sha, None) .await?; let _ = ctx; Ok(row) } pub async fn list_comments( &self, pull_id: Uuid, page: PageQuery, ) -> Result { let (limit, offset) = page.page(); let total = pull_request_comment::Entity::find() .filter(pull_request_comment::Column::PullRequestId.eq(pull_id)) .count(self.db_read()) .await? as i64; let rows = pull_request_comment::Entity::find() .filter(pull_request_comment::Column::PullRequestId.eq(pull_id)) .order_by_asc(pull_request_comment::Column::CreatedAt) .limit(limit) .offset(offset) .all(self.db_read()) .await?; let users = self.comment_users(&rows).await?; Ok(Page { items: rows .iter() .map(|row| Self::comment_dto(row, &users)) .collect(), total, limit, offset, }) } /// 顶层评论 + 嵌套回复的线程树。 pub async fn comment_tree( &self, pull_id: Uuid, ) -> Result, PullError> { let rows = pull_request_comment::Entity::find() .filter(pull_request_comment::Column::PullRequestId.eq(pull_id)) .order_by_asc(pull_request_comment::Column::CreatedAt) .all(self.db_read()) .await?; let users = self.comment_users(&rows).await?; let mut by_parent: HashMap> = HashMap::new(); let mut roots: Vec<&pull_request_comment::Model> = Vec::new(); for row in &rows { match row.parent_comment_id { Some(parent) => by_parent.entry(parent).or_default().push(row), None => roots.push(row), } } Ok(roots .into_iter() .map(|row| PullCommentThreadDto { root: Self::comment_dto(row, &users), replies: by_parent .get(&row.id) .map(|children| { children .iter() .map(|child| Self::comment_dto(child, &users)) .collect() }) .unwrap_or_default(), }) .collect()) } pub async fn update_comment( &self, pull: &pull_requests::Model, comment_id: Uuid, caller: i64, admin: bool, body: String, ) -> Result { let row = self.find_comment(pull.id, comment_id).await?; if row.user_id != caller && !admin { return Err(PullError::Forbidden); } if body.trim().is_empty() || body.chars().count() > MAX_COMMENT_LEN { return Err(PullError::BadRequest("invalid comment body".to_string())); } let mut model: pull_request_comment::ActiveModel = row.into(); model.body = Set(body); model.updated_at = Set(Some(OffsetDateTime::now_utc())); let updated = model.update(self.db_write()).await?; let _ = pull; Ok(updated) } pub async fn delete_comment( &self, pull: &pull_requests::Model, comment_id: Uuid, caller: i64, admin: bool, ) -> Result<(), PullError> { let row = self.find_comment(pull.id, comment_id).await?; if row.user_id != caller && !admin { return Err(PullError::Forbidden); } pull_request_comment::Entity::delete_many() .filter( Condition::any() .add(pull_request_comment::Column::Id.eq(comment_id)) .add(pull_request_comment::Column::ParentCommentId.eq(comment_id)), ) .exec(self.db_write()) .await?; Ok(()) } pub async fn set_comment_resolved( &self, pull: &pull_requests::Model, comment_id: Uuid, caller: i64, resolved: bool, ) -> Result { let row = self.find_comment(pull.id, comment_id).await?; if row.path.is_none() { return Err(PullError::BadRequest( "only inline review comments can be resolved".to_string(), )); } let mut model: pull_request_comment::ActiveModel = row.into(); model.resolved_at = Set(if resolved { Some(OffsetDateTime::now_utc()) } else { None }); model.resolved_by = Set(if resolved { Some(caller) } else { None }); let updated = model.update(self.db_write()).await?; let _ = pull; Ok(updated) } pub(crate) async fn find_comment( &self, pull_id: Uuid, comment_id: Uuid, ) -> Result { pull_request_comment::Entity::find_by_id(comment_id) .filter(pull_request_comment::Column::PullRequestId.eq(pull_id)) .one(self.db_read()) .await? .ok_or(PullError::NotFound) } async fn comment_users( &self, rows: &[pull_request_comment::Model], ) -> Result, PullError> { self.user_briefs(&reviews::unique_ids(rows.iter().map(|row| row.user_id))) .await } fn comment_dto( row: &pull_request_comment::Model, users: &HashMap, ) -> PullCommentDto { PullCommentDto { id: row.id, pull_request_id: row.pull_request_id, author: users.get(&row.user_id).cloned(), body: row.body.clone(), path: row.path.clone(), line: row.line, side: row.side.clone(), commit_sha: row.commit_sha.clone(), review_id: row.review_id, parent_comment_id: row.parent_comment_id, resolved: row.resolved_at.is_some(), resolved_by: row.resolved_by, created_at: row.created_at, updated_at: row.updated_at, } } } impl PullService { /// 单条评论视图(含作者信息)。 pub async fn comment_dto_for( &self, row: &pull_request_comment::Model, ) -> Result { let users = self.comment_users(std::slice::from_ref(row)).await?; Ok(Self::comment_dto(row, &users)) } }