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, pub path: Option, pub side: Option, pub line: Option, pub old_line: Option, /// ID of the parent comment to reply to (null = root comment). pub in_reply_to: Option, } #[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, pub path: Option, pub side: Option, pub line: Option, pub old_line: Option, pub body: String, pub author: Uuid, pub author_username: Option, pub resolved: bool, pub in_reply_to: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } impl From 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, /// Filter by resolved status. Omit to return all comments. pub resolved: Option, /// 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, } /// 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, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReviewCommentListResponse { /// Flat list of all comments (kept for backward compatibility). pub comments: Vec, /// Comments grouped into threads (root comments with their replies). pub threads: Vec, 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 { 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 = 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 = 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 = Vec::new(); // Build a map of parent_comment_id → list of reply responses. let mut reply_map: std::collections::HashMap> = 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 { 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> = 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::>() .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 { 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 { 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 { 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 { 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> = 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::>() .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) } } }