use crate::AppService; use crate::error::AppError; use crate::project::activity::ActivityLogParams; use chrono::Utc; use models::projects::{MemberRole, project_members}; use models::pull_request::{ReviewState, pull_request, pull_request_review}; use models::repos::repo; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct ReviewSubmitRequest { pub body: Option, pub state: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct ReviewUpdateRequest { pub body: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReviewResponse { pub repo: Uuid, pub number: i64, pub reviewer: Uuid, pub reviewer_username: Option, pub state: String, pub body: Option, pub submitted_at: Option>, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } impl From for ReviewResponse { fn from(r: pull_request_review::Model) -> Self { Self { repo: r.repo, number: r.number, reviewer: r.reviewer, reviewer_username: None, state: r.state, body: r.body, submitted_at: r.submitted_at, created_at: r.created_at, updated_at: r.updated_at, } } } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReviewListResponse { pub reviews: Vec, } impl AppService { /// List all reviews on a pull request. pub async fn review_list( &self, namespace: String, repo_name: String, pr_number: i64, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let reviews = pull_request_review::Entity::find() .filter(pull_request_review::Column::Repo.eq(repo.id)) .filter(pull_request_review::Column::Number.eq(pr_number)) .order_by_asc(pull_request_review::Column::SubmittedAt) .all(&self.db) .await?; let reviewer_ids: Vec = reviews.iter().map(|r| r.reviewer).collect(); let reviewers = if reviewer_ids.is_empty() { vec![] } else { models::users::user::Entity::find() .filter(models::users::user::Column::Uid.is_in(reviewer_ids)) .all(&self.db) .await? }; let responses: Vec = reviews .into_iter() .map(|r| { let username = reviewers .iter() .find(|u| u.uid == r.reviewer) .map(|u| u.username.clone()); ReviewResponse { reviewer_username: username, ..ReviewResponse::from(r) } }) .collect(); Ok(ReviewListResponse { reviews: responses }) } /// Submit a review on a pull request. /// Any project member can submit a review. /// Updates existing pending review if found, otherwise creates new. pub async fn review_submit( &self, namespace: String, repo_name: String, pr_number: i64, request: ReviewSubmitRequest, 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()))?; // Cannot review merged PRs if pr.status == models::pull_request::PrStatus::Merged.to_string() { return Err(AppError::BadRequest( "Cannot review a merged pull request".to_string(), )); } // Parse and validate review state let state: ReviewState = request.state.parse().map_err(|_| { AppError::BadRequest( "Invalid review state, expected: pending, approved, changes_requested, comment" .to_string(), ) })?; let now = Utc::now(); let submitted_at = if state == ReviewState::Pending { None } else { Some(now) }; // Check if reviewer already has a review let existing = pull_request_review::Entity::find() .filter(pull_request_review::Column::Repo.eq(repo.id)) .filter(pull_request_review::Column::Number.eq(pr_number)) .filter(pull_request_review::Column::Reviewer.eq(user_uid)) .one(&self.db) .await?; let model = if let Some(existing) = existing { let mut active: pull_request_review::ActiveModel = existing.into(); active.state = Set(state.to_string()); if let Some(body) = request.body { active.body = Set(Some(body)); } active.submitted_at = Set(submitted_at); active.updated_at = Set(now); active.update(&self.db).await? } else { let active = pull_request_review::ActiveModel { repo: Set(repo.id), number: Set(pr_number), reviewer: Set(user_uid), state: Set(state.to_string()), body: Set(request.body), submitted_at: Set(submitted_at), created_at: Set(now), updated_at: Set(now), ..Default::default() }; 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); let reviewer_name = username.clone().unwrap_or_else(|| user_uid.to_string()); let _ = self .project_log_activity( repo.project, Some(repo.id), user_uid, ActivityLogParams { event_type: "pr_review".to_string(), title: format!("{} reviewed PR #{}: {}", reviewer_name, pr_number, state), repo_id: Some(repo.id), content: None, event_id: None, event_sub_id: Some(pr_number), metadata: Some(serde_json::json!({ "pr_number": pr_number, "review_state": state.to_string(), })), is_private: false, }, ) .await; Ok(ReviewResponse { reviewer_username: username.clone(), ..ReviewResponse::from(model.clone()) }) } /// Update a review body. Only the reviewer themselves OR admin/owner. pub async fn review_update( &self, namespace: String, repo_name: String, pr_number: i64, request: ReviewUpdateRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let review = pull_request_review::Entity::find() .filter(pull_request_review::Column::Repo.eq(repo.id)) .filter(pull_request_review::Column::Number.eq(pr_number)) .filter(pull_request_review::Column::Reviewer.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NotFound("Review not found".to_string()))?; // Permission: reviewer OR admin/owner 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_reviewer = review.reviewer == user_uid; let is_admin = role == MemberRole::Admin || role == MemberRole::Owner; if !is_reviewer && !is_admin { return Err(AppError::NoPower); } let mut active: pull_request_review::ActiveModel = review.clone().into(); if let Some(body) = request.body { active.body = Set(Some(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.reviewer) .one(&self.db) .await .ok() .flatten() .map(|u| u.username); Ok(ReviewResponse { reviewer_username: username, ..ReviewResponse::from(model) }) } /// Delete a review. Only the reviewer themselves OR admin/owner. pub async fn review_delete( &self, namespace: String, repo_name: String, pr_number: i64, reviewer_id: Uuid, ctx: &Session, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo: repo::Model = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let review = pull_request_review::Entity::find() .filter(pull_request_review::Column::Repo.eq(repo.id)) .filter(pull_request_review::Column::Number.eq(pr_number)) .filter(pull_request_review::Column::Reviewer.eq(reviewer_id)) .one(&self.db) .await? .ok_or(AppError::NotFound("Review not found".to_string()))?; // Permission: reviewer themselves OR admin/owner let is_reviewer = review.reviewer == user_uid; if !is_reviewer { 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)?; if role != MemberRole::Admin && role != MemberRole::Owner { return Err(AppError::NoPower); } } // Cascade delete review comments models::pull_request::PullRequestReviewComment::delete_many() .filter(models::pull_request::pull_request_review_comment::Column::Repo.eq(repo.id)) .filter(models::pull_request::pull_request_review_comment::Column::Number.eq(pr_number)) .filter( models::pull_request::pull_request_review_comment::Column::Review.eq(reviewer_id), ) .exec(&self.db) .await?; pull_request_review::Entity::delete_by_id((repo.id, pr_number, reviewer_id)) .exec(&self.db) .await?; super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await; Ok(()) } }