use api::users::UserDto; use git::facade::GitContext; use models::pull_request::{pull_request_comment, pull_request_review, pull_requests}; use sea_orm::ActiveValue::Set; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set as AmSet, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::PullError; use crate::services::events::{EVENT_REVIEW_DISMISSED, EVENT_REVIEW_SUBMITTED, write_event}; use crate::services::reviewers::ReviewerOutcome; use crate::services::service::PullService; use crate::share::authz; use models::pull_request::pull_request_review::ReviewState; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum ReviewIntent { Approve, RequestChanges, Comment, Pending, } fn intent_state(intent: ReviewIntent) -> ReviewState { match intent { ReviewIntent::Approve => ReviewState::Approved, ReviewIntent::RequestChanges => ReviewState::ChangesRequested, ReviewIntent::Comment => ReviewState::Commented, ReviewIntent::Pending => ReviewState::Pending, } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct InlineDraft { pub path: String, pub line: i32, #[serde(default)] pub side: Option, #[serde(default)] pub body: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct SubmitReviewCmd { pub state: ReviewIntent, #[serde(default)] pub body: Option, #[serde(default)] pub comments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct PullReviewDto { pub id: Uuid, pub pull_request_id: Uuid, pub user: Option, pub state: ReviewState, pub commit_sha: String, pub official: bool, pub content: Option, pub comment_count: u64, pub created_at: OffsetDateTime, pub updated_at: Option, } impl PullService { pub async fn submit_review( &self, ctx: &GitContext, pull: &pull_requests::Model, caller: i64, admin: bool, cmd: SubmitReviewCmd, ) -> Result { authz::require_open(pull)?; if pull.is_locked && !admin { return Err(PullError::Forbidden); } let state = intent_state(cmd.state); if state == ReviewState::Approved && pull.author_id == caller && !admin { return Err(PullError::Forbidden); } for draft in &cmd.comments { if draft.path.trim().is_empty() || draft.line <= 0 { return Err(PullError::BadRequest( "invalid inline comment target".to_string(), )); } } let now = OffsetDateTime::now_utc(); let txn = self.database.write().begin().await?; let review = pull_request_review::ActiveModel { id: Set(Uuid::now_v7()), pull_request_id: Set(pull.id), user_id: Set(caller), commit_sha: Set(pull.head_sha.clone()), official: Set(state == ReviewState::Approved), content: Set(cmd.body.clone()), state: Set(state), created_at: Set(now), updated_at: Set(None), } .insert(&txn) .await?; for draft in &cmd.comments { let body = draft.body.clone().unwrap_or_default(); if body.trim().is_empty() { continue; } pull_request_comment::ActiveModel { id: Set(Uuid::now_v7()), pull_request_id: Set(pull.id), user_id: Set(caller), review_id: Set(Some(review.id)), parent_comment_id: Set(None), commit_sha: Set(Some(pull.head_sha.clone())), original_commit_sha: Set(Some(pull.head_sha.clone())), path: Set(Some(draft.path.clone())), side: Set(Some( draft.side.clone().unwrap_or_else(|| "right".to_string()), )), line: Set(Some(draft.line)), original_line: Set(Some(draft.line)), body: Set(body), resolved_at: Set(None), resolved_by: Set(None), created_at: Set(now), updated_at: Set(None), } .insert(&txn) .await?; } if state != ReviewState::Pending { write_event( &txn, pull, Some(caller), EVENT_REVIEW_SUBMITTED, Some(pull.head_sha.clone()), Some(format!("{state:?}")), ) .await?; } txn.commit().await?; if state != ReviewState::Pending { self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(state)) .await?; } let _ = ctx; Ok(review) } /// 提交一份待提交(pending)评审,并把其草稿行内评论一并发布。 pub async fn publish_pending_review( &self, pull: &pull_requests::Model, review_id: Uuid, caller: i64, admin: bool, cmd: SubmitReviewCmd, ) -> Result { authz::require_open(pull)?; if pull.is_locked && !admin { return Err(PullError::Forbidden); } let target = intent_state(cmd.state); if target == ReviewState::Pending { return Err(PullError::BadRequest( "state must not be pending".to_string(), )); } let review = pull_request_review::Entity::find_by_id(review_id) .filter(pull_request_review::Column::PullRequestId.eq(pull.id)) .one(self.db_read()) .await? .ok_or(PullError::NotFound)?; if review.user_id != caller { return Err(PullError::Forbidden); } if review.state != ReviewState::Pending { return Err(PullError::Conflict("review already submitted".to_string())); } if target == ReviewState::Approved && pull.author_id == caller && !admin { return Err(PullError::Forbidden); } let mut model: pull_request_review::ActiveModel = review.clone().into(); model.state = Set(target); model.official = Set(target == ReviewState::Approved); if cmd.body.is_some() { model.content = AmSet(cmd.body.clone()); } model.updated_at = Set(Some(OffsetDateTime::now_utc())); let commit_sha = review.commit_sha.clone(); let event_pull = pull.clone(); let this = self.clone(); let updated = self .db_write() .transaction::<_, _, PullError>(move |tx| { Box::pin(async move { let updated = model.update(tx).await?; this.record_event_with( tx, &event_pull, Some(caller), EVENT_REVIEW_SUBMITTED, Some(commit_sha), Some(format!("{target:?}")), ) .await?; Ok(updated) }) }) .await?; self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(target)) .await?; Ok(updated) } pub async fn dismiss_review( &self, pull: &pull_requests::Model, review_id: Uuid, caller: i64, ) -> Result { let review = pull_request_review::Entity::find_by_id(review_id) .filter(pull_request_review::Column::PullRequestId.eq(pull.id)) .one(self.db_read()) .await? .ok_or(PullError::NotFound)?; if review.state == ReviewState::Dismissed { return Ok(review); } let mut model: pull_request_review::ActiveModel = review.clone().into(); model.state = Set(ReviewState::Dismissed); model.official = Set(false); model.updated_at = Set(Some(OffsetDateTime::now_utc())); let commit_sha = review.commit_sha.clone(); let user_id = review.user_id; let event_pull = pull.clone(); let this = self.clone(); let updated = self .db_write() .transaction::<_, _, PullError>(move |tx| { Box::pin(async move { let updated = model.update(tx).await?; this.record_event_with( tx, &event_pull, Some(caller), EVENT_REVIEW_DISMISSED, Some(commit_sha), Some(format!("dismissed review of {user_id:?}")), ) .await?; Ok(updated) }) }) .await?; self.sync_reviewer(pull.id, review.user_id, ReviewerOutcome::Dismissed) .await?; Ok(updated) } pub async fn list_reviews( &self, pull_id: Uuid, viewer: Option, ) -> Result, PullError> { let rows = pull_request_review::Entity::find() .filter(pull_request_review::Column::PullRequestId.eq(pull_id)) .order_by_asc(pull_request_review::Column::CreatedAt) .all(self.db_read()) .await?; let visible: Vec = rows .into_iter() .filter(|row| row.state != ReviewState::Pending || Some(row.user_id) == viewer) .collect(); self.review_dtos(&visible).await } pub async fn get_review( &self, pull_id: Uuid, review_id: Uuid, viewer: Option, ) -> Result { let row = pull_request_review::Entity::find_by_id(review_id) .filter(pull_request_review::Column::PullRequestId.eq(pull_id)) .one(self.db_read()) .await? .ok_or(PullError::NotFound)?; if row.state == ReviewState::Pending && Some(row.user_id) != viewer { return Err(PullError::NotFound); } let dtos = self.review_dtos(std::slice::from_ref(&row)).await?; dtos.into_iter().next().ok_or(PullError::NotFound) } async fn review_dtos( &self, rows: &[pull_request_review::Model], ) -> Result, PullError> { let users = self .actors .briefs(&unique_ids(rows.iter().map(|row| row.user_id))) .await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { let comment_count = pull_request_comment::Entity::find() .filter(pull_request_comment::Column::ReviewId.eq(row.id)) .count(self.db_read()) .await?; out.push(PullReviewDto { id: row.id, pull_request_id: row.pull_request_id, user: users.get(&row.user_id).cloned(), state: row.state, commit_sha: row.commit_sha.clone(), official: row.official, content: row.content.clone(), comment_count, created_at: row.created_at, updated_at: row.updated_at, }); } Ok(out) } } pub(crate) fn unique_ids(iter: impl Iterator) -> Vec { let mut ids: Vec = iter.collect(); ids.sort(); ids.dedup(); ids }