25f6a03
use export::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<String>,
#[serde(default)]
pub body: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SubmitReviewCmd {
pub state: ReviewIntent,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub comments: Vec<InlineDraft>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PullReviewDto {
pub id: Uuid,
pub pull_request_id: Uuid,
pub user: Option<UserDto>,
pub state: ReviewState,
pub commit_sha: String,
pub official: bool,
pub content: Option<String>,
pub comment_count: u64,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
impl PullService {
pub async fn submit_review(
&self,
ctx: &GitContext,
pull: &pull_requests::Model,
caller: i64,
admin: bool,
cmd: SubmitReviewCmd,
) -> Result<pull_request_review::Model, PullError> {
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<pull_request_review::Model, PullError> {
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<pull_request_review::Model, PullError> {
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<i64>,
) -> Result<Vec<PullReviewDto>, 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<pull_request_review::Model> = 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<i64>,
) -> Result<PullReviewDto, PullError> {
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<Vec<PullReviewDto>, 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<Item = i64>) -> Vec<i64> {
let mut ids: Vec<i64> = iter.collect();
ids.sort();
ids.dedup();
ids
}