gitdataai/libs/service/pull_request/review_comment.rs
2026-04-15 09:08:09 +08:00

562 lines
19 KiB
Rust

use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::projects::{MemberRole, project_members};
use models::pull_request::{pull_request, pull_request_review_comment};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct ReviewCommentCreateRequest {
pub body: String,
pub review: Option<Uuid>,
pub path: Option<String>,
pub side: Option<String>,
pub line: Option<i64>,
pub old_line: Option<i64>,
/// ID of the parent comment to reply to (null = root comment).
pub in_reply_to: Option<i64>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct ReviewCommentUpdateRequest {
pub body: String,
}
/// Body for replying to an existing review comment thread.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct ReviewCommentReplyRequest {
pub body: String,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ReviewCommentResponse {
pub repo: Uuid,
pub number: i64,
pub id: i64,
pub review: Option<Uuid>,
pub path: Option<String>,
pub side: Option<String>,
pub line: Option<i64>,
pub old_line: Option<i64>,
pub body: String,
pub author: Uuid,
pub author_username: Option<String>,
pub resolved: bool,
pub in_reply_to: Option<i64>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
impl From<pull_request_review_comment::Model> for ReviewCommentResponse {
fn from(c: pull_request_review_comment::Model) -> Self {
Self {
repo: c.repo,
number: c.number,
id: c.id,
review: c.review,
path: c.path,
side: c.side,
line: c.line,
old_line: c.old_line,
body: c.body,
author: c.author,
author_username: None,
resolved: c.resolved,
in_reply_to: c.in_reply_to,
created_at: c.created_at,
updated_at: c.updated_at,
}
}
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct ReviewCommentListQuery {
/// Filter comments by file path (e.g. "src/main.rs").
pub path: Option<String>,
/// Filter by resolved status. Omit to return all comments.
pub resolved: Option<bool>,
/// If true, only return inline comments (those with a `path` set).
/// If false, only return general comments (no path).
/// Omit to return all comments.
pub file_only: Option<bool>,
}
/// A review comment thread: one root comment plus all its replies.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ReviewCommentThread {
pub root: ReviewCommentResponse,
pub replies: Vec<ReviewCommentResponse>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ReviewCommentListResponse {
/// Flat list of all comments (kept for backward compatibility).
pub comments: Vec<ReviewCommentResponse>,
/// Comments grouped into threads (root comments with their replies).
pub threads: Vec<ReviewCommentThread>,
pub total: i64,
}
impl AppService {
/// List review comments on a pull request, optionally filtered and grouped into threads.
pub async fn review_comment_list(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
query: ReviewCommentListQuery,
ctx: &Session,
) -> Result<ReviewCommentListResponse, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let mut stmt = pull_request_review_comment::Entity::find()
.filter(pull_request_review_comment::Column::Repo.eq(repo.id))
.filter(pull_request_review_comment::Column::Number.eq(pr_number))
.order_by_asc(pull_request_review_comment::Column::Path)
.order_by_asc(pull_request_review_comment::Column::Line)
.order_by_asc(pull_request_review_comment::Column::CreatedAt);
if let Some(ref path) = query.path {
stmt = stmt.filter(pull_request_review_comment::Column::Path.eq(path.clone()));
}
if let Some(resolved) = query.resolved {
stmt = stmt.filter(pull_request_review_comment::Column::Resolved.eq(resolved));
}
if query.file_only == Some(true) {
stmt = stmt.filter(pull_request_review_comment::Column::Path.is_not_null());
} else if query.file_only == Some(false) {
stmt = stmt.filter(pull_request_review_comment::Column::Path.is_null());
}
let comments = stmt.all(&self.db).await?;
let total = comments.len() as i64;
let author_ids: Vec<Uuid> = comments.iter().map(|c| c.author).collect();
let authors = if author_ids.is_empty() {
vec![]
} else {
models::users::user::Entity::find()
.filter(models::users::user::Column::Uid.is_in(author_ids))
.all(&self.db)
.await?
};
let responses: Vec<ReviewCommentResponse> = comments
.iter()
.map(|c| {
let username = authors
.iter()
.find(|u| u.uid == c.author)
.map(|u| u.username.clone());
ReviewCommentResponse {
author_username: username,
..ReviewCommentResponse::from(c.clone())
}
})
.collect();
// Group into threads: root comments (in_reply_to IS NULL) with their replies.
let mut threads: Vec<ReviewCommentThread> = Vec::new();
// Build a map of parent_comment_id → list of reply responses.
let mut reply_map: std::collections::HashMap<i64, Vec<ReviewCommentResponse>> =
std::collections::HashMap::new();
for comment in &responses {
if let Some(parent_id) = comment.in_reply_to {
reply_map
.entry(parent_id)
.or_default()
.push(comment.clone());
}
}
// Root comments are those with no parent.
for comment in &responses {
if comment.in_reply_to.is_none() {
let replies = reply_map.remove(&comment.id).unwrap_or_default();
threads.push(ReviewCommentThread {
root: comment.clone(),
replies,
});
}
}
// Sort threads: by file path, then by line number of root comment.
threads.sort_by(|a, b| {
let path_cmp = a.root.path.cmp(&b.root.path);
if path_cmp != std::cmp::Ordering::Equal {
return path_cmp;
}
a.root.line.cmp(&b.root.line)
});
Ok(ReviewCommentListResponse {
comments: responses,
threads,
total,
})
}
pub async fn review_comment_create(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
request: ReviewCommentCreateRequest,
ctx: &Session,
) -> Result<ReviewCommentResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
// Verify PR exists
let pr = pull_request::Entity::find()
.filter(pull_request::Column::Repo.eq(repo.id))
.filter(pull_request::Column::Number.eq(pr_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pull request not found".to_string()))?;
if pr.status == models::pull_request::PrStatus::Merged.to_string() {
return Err(AppError::BadRequest(
"Cannot comment on a merged pull request".to_string(),
));
}
// Get next comment id for this PR
let max_id: Option<Option<i64>> = pull_request_review_comment::Entity::find()
.filter(pull_request_review_comment::Column::Repo.eq(repo.id))
.filter(pull_request_review_comment::Column::Number.eq(pr_number))
.select_only()
.column_as(pull_request_review_comment::Column::Id.max(), "max_id")
.into_tuple::<Option<i64>>()
.one(&self.db)
.await?;
let comment_id = max_id.flatten().unwrap_or(0) + 1;
let now = Utc::now();
let active = pull_request_review_comment::ActiveModel {
repo: Set(repo.id),
number: Set(pr_number),
id: Set(comment_id),
review: Set(request.review),
path: Set(request.path),
side: Set(request.side),
line: Set(request.line),
old_line: Set(request.old_line),
body: Set(request.body),
author: Set(user_uid),
resolved: Set(false),
in_reply_to: Set(request.in_reply_to),
created_at: Set(now),
updated_at: Set(now),
};
let model = active.insert(&self.db).await?;
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
let username = models::users::user::Entity::find_by_id(user_uid)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username);
Ok(ReviewCommentResponse {
author_username: username,
..ReviewCommentResponse::from(model)
})
}
pub async fn review_comment_update(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
comment_id: i64,
request: ReviewCommentUpdateRequest,
ctx: &Session,
) -> Result<ReviewCommentResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let comment =
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
// Permission: author OR admin/owner
let is_author = comment.author == user_uid;
let member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(repo.project))
.filter(project_members::Column::User.eq(user_uid))
.one(&self.db)
.await?
.ok_or(AppError::NoPower)?;
let role = member.scope_role().map_err(|_| AppError::RoleParseError)?;
let is_admin = role == MemberRole::Admin || role == MemberRole::Owner;
if !is_author && !is_admin {
return Err(AppError::NoPower);
}
let mut active: pull_request_review_comment::ActiveModel = comment.clone().into();
active.body = Set(request.body);
active.updated_at = Set(Utc::now());
let model = active.update(&self.db).await?;
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
let username = models::users::user::Entity::find_by_id(model.author)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username);
Ok(ReviewCommentResponse {
author_username: username,
..ReviewCommentResponse::from(model)
})
}
pub async fn review_comment_delete(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
comment_id: i64,
ctx: &Session,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let comment =
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
// Permission: author OR admin/owner
let is_author = comment.author == user_uid;
let member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(repo.project))
.filter(project_members::Column::User.eq(user_uid))
.one(&self.db)
.await?
.ok_or(AppError::NoPower)?;
let role = member.scope_role().map_err(|_| AppError::RoleParseError)?;
let is_admin = role == MemberRole::Admin || role == MemberRole::Owner;
if !is_author && !is_admin {
return Err(AppError::NoPower);
}
pull_request_review_comment::Entity::delete_by_id((repo.id, pr_number, comment_id))
.exec(&self.db)
.await?;
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
Ok(())
}
/// Mark a review comment (root of a thread) as resolved.
pub async fn review_comment_resolve(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
comment_id: i64,
ctx: &Session,
) -> Result<ReviewCommentResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let comment =
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
self.check_comment_permission(&repo, &comment, user_uid)
.await?;
let mut active: pull_request_review_comment::ActiveModel = comment.clone().into();
active.resolved = Set(true);
active.updated_at = Set(Utc::now());
let model = active.update(&self.db).await?;
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
let username = models::users::user::Entity::find_by_id(model.author)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username);
Ok(ReviewCommentResponse {
author_username: username,
..ReviewCommentResponse::from(model)
})
}
/// Mark a review comment thread as unresolved.
pub async fn review_comment_unresolve(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
comment_id: i64,
ctx: &Session,
) -> Result<ReviewCommentResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let comment =
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
self.check_comment_permission(&repo, &comment, user_uid)
.await?;
let mut active: pull_request_review_comment::ActiveModel = comment.clone().into();
active.resolved = Set(false);
active.updated_at = Set(Utc::now());
let model = active.update(&self.db).await?;
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
let username = models::users::user::Entity::find_by_id(model.author)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username);
Ok(ReviewCommentResponse {
author_username: username,
..ReviewCommentResponse::from(model)
})
}
/// Reply to an existing review comment, creating a threaded reply.
pub async fn review_comment_reply(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
parent_comment_id: i64,
request: ReviewCommentReplyRequest,
ctx: &Session,
) -> Result<ReviewCommentResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
// Verify PR exists and is not merged
let pr = pull_request::Entity::find()
.filter(pull_request::Column::Repo.eq(repo.id))
.filter(pull_request::Column::Number.eq(pr_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pull request not found".to_string()))?;
if pr.status == models::pull_request::PrStatus::Merged.to_string() {
return Err(AppError::BadRequest(
"Cannot comment on a merged pull request".to_string(),
));
}
// Verify parent comment exists
let parent = pull_request_review_comment::Entity::find_by_id((
repo.id,
pr_number,
parent_comment_id,
))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Parent comment not found".to_string()))?;
// Get next comment id
let max_id: Option<Option<i64>> = pull_request_review_comment::Entity::find()
.filter(pull_request_review_comment::Column::Repo.eq(repo.id))
.filter(pull_request_review_comment::Column::Number.eq(pr_number))
.select_only()
.column_as(pull_request_review_comment::Column::Id.max(), "max_id")
.into_tuple::<Option<i64>>()
.one(&self.db)
.await?;
let comment_id = max_id.flatten().unwrap_or(0) + 1;
let now = Utc::now();
let active = pull_request_review_comment::ActiveModel {
repo: Set(repo.id),
number: Set(pr_number),
id: Set(comment_id),
review: Set(None),
path: Set(parent.path.clone()),
side: Set(parent.side.clone()),
line: Set(parent.line),
old_line: Set(parent.old_line),
body: Set(request.body),
author: Set(user_uid),
resolved: Set(false),
in_reply_to: Set(Some(parent.id)),
created_at: Set(now),
updated_at: Set(now),
};
let model = active.insert(&self.db).await?;
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
let username = models::users::user::Entity::find_by_id(user_uid)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username);
Ok(ReviewCommentResponse {
author_username: username,
..ReviewCommentResponse::from(model)
})
}
async fn check_comment_permission(
&self,
repo: &models::repos::repo::Model,
comment: &pull_request_review_comment::Model,
user_uid: Uuid,
) -> Result<(), AppError> {
// Authors can always modify their own comments
if comment.author == user_uid {
return Ok(());
}
// Admins/owners can modify any comment
let member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(repo.project))
.filter(project_members::Column::User.eq(user_uid))
.one(&self.db)
.await?
.ok_or(AppError::NoPower)?;
let role = member.scope_role().map_err(|_| AppError::RoleParseError)?;
let is_admin = role == MemberRole::Admin || role == MemberRole::Owner;
if is_admin {
Ok(())
} else {
Err(AppError::NoPower)
}
}
}