use crate::AppService; use crate::error::AppError; use chrono::Utc; use models::projects::{MemberRole, project_members}; use models::pull_request::{PrStatus, pull_request, pull_request_review_request}; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct ReviewRequestCreateRequest { /// User ID of the reviewer to request. pub reviewer: Uuid, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReviewRequestResponse { pub repo: Uuid, pub number: i64, pub reviewer: Uuid, pub reviewer_username: Option, pub requested_by: Uuid, pub requested_by_username: Option, pub requested_at: chrono::DateTime, pub dismissed_at: Option>, pub dismissed_by: Option, pub dismissed_by_username: Option, } impl From for ReviewRequestResponse { fn from(m: pull_request_review_request::Model) -> Self { Self { repo: m.repo, number: m.number, reviewer: m.reviewer, reviewer_username: None, requested_by: m.requested_by, requested_by_username: None, requested_at: m.requested_at, dismissed_at: m.dismissed_at, dismissed_by: m.dismissed_by, dismissed_by_username: None, } } } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReviewRequestListResponse { pub requests: Vec, pub total: i64, } impl AppService { /// Request a review from a specific user for a pull request. /// Any PR collaborator can request a review. pub async fn review_request_create( &self, namespace: String, repo_name: String, pr_number: i64, request: ReviewRequestCreateRequest, 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 == PrStatus::Merged.to_string() { return Err(AppError::BadRequest( "Cannot request review on a merged pull request".to_string(), )); } // Check: reviewer must be a project member or collaborator let is_member = project_members::Entity::find() .filter(project_members::Column::Project.eq(repo.project)) .filter(project_members::Column::User.eq(request.reviewer)) .one(&self.db) .await? .is_some(); let is_collaborator = models::repos::repo_collaborator::Entity::find() .filter(models::repos::repo_collaborator::Column::Repo.eq(repo.id)) .filter(models::repos::repo_collaborator::Column::User.eq(request.reviewer)) .one(&self.db) .await? .is_some(); if !is_member && !is_collaborator { return Err(AppError::NoPower); } let now = Utc::now(); // Upsert: update requested_at if request already exists let existing = pull_request_review_request::Entity::find() .filter(pull_request_review_request::Column::Repo.eq(repo.id)) .filter(pull_request_review_request::Column::Number.eq(pr_number)) .filter(pull_request_review_request::Column::Reviewer.eq(request.reviewer)) .one(&self.db) .await?; let model = if let Some(existing) = existing { let mut active: pull_request_review_request::ActiveModel = existing.into(); active.requested_by = Set(user_uid); active.requested_at = Set(now); active.dismissed_at = Set(None); active.dismissed_by = Set(None); active.update(&self.db).await? } else { let active = pull_request_review_request::ActiveModel { repo: Set(repo.id), number: Set(pr_number), reviewer: Set(request.reviewer), requested_by: Set(user_uid), requested_at: Set(now), dismissed_at: Set(None), dismissed_by: Set(None), }; active.insert(&self.db).await? }; super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await; // Load usernames let reviewer_username = models::users::user::Entity::find_by_id(model.reviewer) .one(&self.db) .await .ok() .flatten() .map(|u| u.username); let requested_by_username = models::users::user::Entity::find_by_id(model.requested_by) .one(&self.db) .await .ok() .flatten() .map(|u| u.username); Ok(ReviewRequestResponse { reviewer_username, requested_by_username, ..ReviewRequestResponse::from(model) }) } /// List all review requests for a pull request. pub async fn review_request_list( &self, namespace: String, repo_name: String, pr_number: 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 requests = pull_request_review_request::Entity::find() .filter(pull_request_review_request::Column::Repo.eq(repo.id)) .filter(pull_request_review_request::Column::Number.eq(pr_number)) .order_by_asc(pull_request_review_request::Column::RequestedAt) .all(&self.db) .await?; let total = requests.len() as i64; // Batch load usernames let all_ids: Vec = requests .iter() .flat_map(|r| { std::iter::once(r.reviewer) .chain(std::iter::once(r.requested_by)) .chain(r.dismissed_by) }) .collect(); let users = if all_ids.is_empty() { vec![] } else { models::users::user::Entity::find() .filter(models::users::user::Column::Uid.is_in(all_ids)) .all(&self.db) .await? }; let username_map: std::collections::HashMap = users.into_iter().map(|u| (u.uid, u.username)).collect(); let responses: Vec = requests .into_iter() .map(|r| ReviewRequestResponse { reviewer_username: username_map.get(&r.reviewer).cloned(), requested_by_username: username_map.get(&r.requested_by).cloned(), dismissed_by_username: r.dismissed_by.and_then(|id| username_map.get(&id).cloned()), ..ReviewRequestResponse::from(r) }) .collect(); Ok(ReviewRequestListResponse { requests: responses, total, }) } pub async fn review_request_delete( &self, namespace: String, repo_name: String, pr_number: i64, reviewer: Uuid, 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 existing = pull_request_review_request::Entity::find_by_id((repo.id, pr_number, reviewer)) .one(&self.db) .await? .ok_or(AppError::NotFound("Review request not found".to_string()))?; // Permission: requested_by user OR admin/owner let is_requester = existing.requested_by == 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_requester && !is_admin { return Err(AppError::NoPower); } pull_request_review_request::Entity::delete_by_id((repo.id, pr_number, reviewer)) .exec(&self.db) .await?; super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await; Ok(()) } /// Dismiss (mark as no longer needed) a pending review request. /// Unlike delete, this records who dismissed it and when. pub async fn review_request_dismiss( &self, namespace: String, repo_name: String, pr_number: i64, reviewer: Uuid, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let existing = pull_request_review_request::Entity::find_by_id((repo.id, pr_number, reviewer)) .one(&self.db) .await? .ok_or(AppError::NotFound("Review request not found".to_string()))?; // Permission: requested_by user OR admin/owner let is_requester = existing.requested_by == 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_requester && !is_admin { return Err(AppError::NoPower); } let now = Utc::now(); let mut active: pull_request_review_request::ActiveModel = existing.into(); active.dismissed_at = Set(Some(now)); active.dismissed_by = Set(Some(user_uid)); let model = active.update(&self.db).await?; super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await; let reviewer_username = models::users::user::Entity::find_by_id(model.reviewer) .one(&self.db) .await .ok() .flatten() .map(|u| u.username); let requested_by_username = models::users::user::Entity::find_by_id(model.requested_by) .one(&self.db) .await .ok() .flatten() .map(|u| u.username); let dismissed_by_username = if let Some(id) = model.dismissed_by { models::users::user::Entity::find_by_id(id) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) } else { None }; Ok(ReviewRequestResponse { reviewer_username, requested_by_username, dismissed_by_username, ..ReviewRequestResponse::from(model) }) } }