Skip to content
Clone
ef0b83a
363 lines 12.2 KB Rust
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<String>,
#[serde(default)]
pub line: Option<i32>,
/// left | right
#[serde(default)]
pub side: Option<String>,
#[serde(default)]
pub commit_sha: Option<String>,
#[serde(default)]
pub review_id: Option<Uuid>,
#[serde(default)]
pub parent_comment_id: Option<Uuid>,
}
#[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<UserDto>,
pub body: String,
pub path: Option<String>,
pub line: Option<i32>,
pub side: Option<String>,
pub commit_sha: Option<String>,
pub review_id: Option<Uuid>,
pub parent_comment_id: Option<Uuid>,
pub resolved: bool,
pub resolved_by: Option<i64>,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
/// 线程视图:顶层评论 + 其下全部回复(无递归,便于文档生成)。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PullCommentThreadDto {
pub root: PullCommentDto,
pub replies: Vec<PullCommentDto>,
}
pub type CommentPage = Page<PullCommentDto>;
impl PullService {
pub async fn create_comment(
&self,
ctx: &GitContext,
pull: &pull_requests::Model,
caller: i64,
cmd: CreatePullCommentCmd,
) -> Result<pull_request_comment::Model, PullError> {
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<CommentPage, PullError> {
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<Vec<PullCommentThreadDto>, 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<Uuid, Vec<&pull_request_comment::Model>> = 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<pull_request_comment::Model, PullError> {
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<pull_request_comment::Model, PullError> {
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::Model, PullError> {
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<HashMap<i64, UserDto>, 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<i64, UserDto>,
) -> 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<PullCommentDto, PullError> {
let users = self.comment_users(std::slice::from_ref(row)).await?;
Ok(Self::comment_dto(row, &users))
}
}