gitdataai/libs/service/pull_request/review.rs
2026-04-15 09:08:09 +08:00

329 lines
11 KiB
Rust

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<String>,
pub state: String,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct ReviewUpdateRequest {
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ReviewResponse {
pub repo: Uuid,
pub number: i64,
pub reviewer: Uuid,
pub reviewer_username: Option<String>,
pub state: String,
pub body: Option<String>,
pub submitted_at: Option<chrono::DateTime<Utc>>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
impl From<pull_request_review::Model> 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<ReviewResponse>,
}
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<ReviewListResponse, AppError> {
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<Uuid> = 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<ReviewResponse> = 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<ReviewResponse, AppError> {
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<ReviewResponse, AppError> {
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(())
}
}