329 lines
11 KiB
Rust
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(())
|
|
}
|
|
}
|