39d7e6b
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<UserDto>,
pub parent_id: Option<Uuid>,
pub body: String,
pub resolved: bool,
pub resolved_by: Option<Uuid>,
/// 该评论锚定的提交是否仍是当前分支提示(否则视为 outdated)。
pub outdated: bool,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
pub type CodeCommentPage = Page<CodeCommentDto>;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateCodeCommentCmd {
pub path: String,
pub line: i32,
/// 锚定的提交;缺省使用该 revision 解析出的提交。
#[serde(default)]
pub commit_sha: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default)]
pub parent_id: Option<Uuid>,
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<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default)]
pub resolved: Option<bool>,
#[serde(default)]
pub limit: Option<u64>,
#[serde(default)]
pub offset: Option<u64>,
}
impl super::GitService {
pub async fn list_code_comments(
&self,
space_name: String,
repo_name: String,
query: ListCodeCommentsQuery,
) -> Result<CodeCommentPage, GitError> {
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: Uuid,
cmd: CreateCodeCommentCmd,
) -> Result<CodeCommentDto, GitError> {
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: Uuid,
admin: bool,
cmd: UpdateCodeCommentCmd,
) -> Result<CodeCommentDto, GitError> {
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: Uuid,
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: Uuid,
resolved: bool,
) -> Result<CodeCommentDto, GitError> {
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::Model, GitError> {
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<code_comment::Model>,
) -> Result<Vec<CodeCommentDto>, GitError> {
if rows.is_empty() {
return Ok(Vec::new());
}
let uids: Vec<Uuid> = 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())
}
}