562 lines
19 KiB
Rust
562 lines
19 KiB
Rust
use crate::AppService;
|
|
use crate::error::AppError;
|
|
use chrono::Utc;
|
|
use models::projects::{MemberRole, project_members};
|
|
use models::pull_request::{pull_request, pull_request_review_comment};
|
|
use sea_orm::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use session::Session;
|
|
use utoipa::ToSchema;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
|
pub struct ReviewCommentCreateRequest {
|
|
pub body: String,
|
|
pub review: Option<Uuid>,
|
|
pub path: Option<String>,
|
|
pub side: Option<String>,
|
|
pub line: Option<i64>,
|
|
pub old_line: Option<i64>,
|
|
/// ID of the parent comment to reply to (null = root comment).
|
|
pub in_reply_to: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
|
pub struct ReviewCommentUpdateRequest {
|
|
pub body: String,
|
|
}
|
|
|
|
/// Body for replying to an existing review comment thread.
|
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
|
pub struct ReviewCommentReplyRequest {
|
|
pub body: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct ReviewCommentResponse {
|
|
pub repo: Uuid,
|
|
pub number: i64,
|
|
pub id: i64,
|
|
pub review: Option<Uuid>,
|
|
pub path: Option<String>,
|
|
pub side: Option<String>,
|
|
pub line: Option<i64>,
|
|
pub old_line: Option<i64>,
|
|
pub body: String,
|
|
pub author: Uuid,
|
|
pub author_username: Option<String>,
|
|
pub resolved: bool,
|
|
pub in_reply_to: Option<i64>,
|
|
pub created_at: chrono::DateTime<Utc>,
|
|
pub updated_at: chrono::DateTime<Utc>,
|
|
}
|
|
|
|
impl From<pull_request_review_comment::Model> for ReviewCommentResponse {
|
|
fn from(c: pull_request_review_comment::Model) -> Self {
|
|
Self {
|
|
repo: c.repo,
|
|
number: c.number,
|
|
id: c.id,
|
|
review: c.review,
|
|
path: c.path,
|
|
side: c.side,
|
|
line: c.line,
|
|
old_line: c.old_line,
|
|
body: c.body,
|
|
author: c.author,
|
|
author_username: None,
|
|
resolved: c.resolved,
|
|
in_reply_to: c.in_reply_to,
|
|
created_at: c.created_at,
|
|
updated_at: c.updated_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
|
pub struct ReviewCommentListQuery {
|
|
/// Filter comments by file path (e.g. "src/main.rs").
|
|
pub path: Option<String>,
|
|
/// Filter by resolved status. Omit to return all comments.
|
|
pub resolved: Option<bool>,
|
|
/// If true, only return inline comments (those with a `path` set).
|
|
/// If false, only return general comments (no path).
|
|
/// Omit to return all comments.
|
|
pub file_only: Option<bool>,
|
|
}
|
|
|
|
/// A review comment thread: one root comment plus all its replies.
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct ReviewCommentThread {
|
|
pub root: ReviewCommentResponse,
|
|
pub replies: Vec<ReviewCommentResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct ReviewCommentListResponse {
|
|
/// Flat list of all comments (kept for backward compatibility).
|
|
pub comments: Vec<ReviewCommentResponse>,
|
|
/// Comments grouped into threads (root comments with their replies).
|
|
pub threads: Vec<ReviewCommentThread>,
|
|
pub total: i64,
|
|
}
|
|
|
|
impl AppService {
|
|
/// List review comments on a pull request, optionally filtered and grouped into threads.
|
|
pub async fn review_comment_list(
|
|
&self,
|
|
namespace: String,
|
|
repo_name: String,
|
|
pr_number: i64,
|
|
query: ReviewCommentListQuery,
|
|
ctx: &Session,
|
|
) -> Result<ReviewCommentListResponse, AppError> {
|
|
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
|
|
|
|
let mut stmt = pull_request_review_comment::Entity::find()
|
|
.filter(pull_request_review_comment::Column::Repo.eq(repo.id))
|
|
.filter(pull_request_review_comment::Column::Number.eq(pr_number))
|
|
.order_by_asc(pull_request_review_comment::Column::Path)
|
|
.order_by_asc(pull_request_review_comment::Column::Line)
|
|
.order_by_asc(pull_request_review_comment::Column::CreatedAt);
|
|
|
|
if let Some(ref path) = query.path {
|
|
stmt = stmt.filter(pull_request_review_comment::Column::Path.eq(path.clone()));
|
|
}
|
|
if let Some(resolved) = query.resolved {
|
|
stmt = stmt.filter(pull_request_review_comment::Column::Resolved.eq(resolved));
|
|
}
|
|
if query.file_only == Some(true) {
|
|
stmt = stmt.filter(pull_request_review_comment::Column::Path.is_not_null());
|
|
} else if query.file_only == Some(false) {
|
|
stmt = stmt.filter(pull_request_review_comment::Column::Path.is_null());
|
|
}
|
|
|
|
let comments = stmt.all(&self.db).await?;
|
|
|
|
let total = comments.len() as i64;
|
|
|
|
let author_ids: Vec<Uuid> = comments.iter().map(|c| c.author).collect();
|
|
let authors = if author_ids.is_empty() {
|
|
vec![]
|
|
} else {
|
|
models::users::user::Entity::find()
|
|
.filter(models::users::user::Column::Uid.is_in(author_ids))
|
|
.all(&self.db)
|
|
.await?
|
|
};
|
|
|
|
let responses: Vec<ReviewCommentResponse> = comments
|
|
.iter()
|
|
.map(|c| {
|
|
let username = authors
|
|
.iter()
|
|
.find(|u| u.uid == c.author)
|
|
.map(|u| u.username.clone());
|
|
ReviewCommentResponse {
|
|
author_username: username,
|
|
..ReviewCommentResponse::from(c.clone())
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Group into threads: root comments (in_reply_to IS NULL) with their replies.
|
|
let mut threads: Vec<ReviewCommentThread> = Vec::new();
|
|
|
|
// Build a map of parent_comment_id → list of reply responses.
|
|
let mut reply_map: std::collections::HashMap<i64, Vec<ReviewCommentResponse>> =
|
|
std::collections::HashMap::new();
|
|
|
|
for comment in &responses {
|
|
if let Some(parent_id) = comment.in_reply_to {
|
|
reply_map
|
|
.entry(parent_id)
|
|
.or_default()
|
|
.push(comment.clone());
|
|
}
|
|
}
|
|
|
|
// Root comments are those with no parent.
|
|
for comment in &responses {
|
|
if comment.in_reply_to.is_none() {
|
|
let replies = reply_map.remove(&comment.id).unwrap_or_default();
|
|
threads.push(ReviewCommentThread {
|
|
root: comment.clone(),
|
|
replies,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort threads: by file path, then by line number of root comment.
|
|
threads.sort_by(|a, b| {
|
|
let path_cmp = a.root.path.cmp(&b.root.path);
|
|
if path_cmp != std::cmp::Ordering::Equal {
|
|
return path_cmp;
|
|
}
|
|
a.root.line.cmp(&b.root.line)
|
|
});
|
|
|
|
Ok(ReviewCommentListResponse {
|
|
comments: responses,
|
|
threads,
|
|
total,
|
|
})
|
|
}
|
|
|
|
pub async fn review_comment_create(
|
|
&self,
|
|
namespace: String,
|
|
repo_name: String,
|
|
pr_number: i64,
|
|
request: ReviewCommentCreateRequest,
|
|
ctx: &Session,
|
|
) -> Result<ReviewCommentResponse, 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()))?;
|
|
|
|
if pr.status == models::pull_request::PrStatus::Merged.to_string() {
|
|
return Err(AppError::BadRequest(
|
|
"Cannot comment on a merged pull request".to_string(),
|
|
));
|
|
}
|
|
|
|
// Get next comment id for this PR
|
|
let max_id: Option<Option<i64>> = pull_request_review_comment::Entity::find()
|
|
.filter(pull_request_review_comment::Column::Repo.eq(repo.id))
|
|
.filter(pull_request_review_comment::Column::Number.eq(pr_number))
|
|
.select_only()
|
|
.column_as(pull_request_review_comment::Column::Id.max(), "max_id")
|
|
.into_tuple::<Option<i64>>()
|
|
.one(&self.db)
|
|
.await?;
|
|
let comment_id = max_id.flatten().unwrap_or(0) + 1;
|
|
|
|
let now = Utc::now();
|
|
let active = pull_request_review_comment::ActiveModel {
|
|
repo: Set(repo.id),
|
|
number: Set(pr_number),
|
|
id: Set(comment_id),
|
|
review: Set(request.review),
|
|
path: Set(request.path),
|
|
side: Set(request.side),
|
|
line: Set(request.line),
|
|
old_line: Set(request.old_line),
|
|
body: Set(request.body),
|
|
author: Set(user_uid),
|
|
resolved: Set(false),
|
|
in_reply_to: Set(request.in_reply_to),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
};
|
|
let model = 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);
|
|
|
|
Ok(ReviewCommentResponse {
|
|
author_username: username,
|
|
..ReviewCommentResponse::from(model)
|
|
})
|
|
}
|
|
|
|
pub async fn review_comment_update(
|
|
&self,
|
|
namespace: String,
|
|
repo_name: String,
|
|
pr_number: i64,
|
|
comment_id: i64,
|
|
request: ReviewCommentUpdateRequest,
|
|
ctx: &Session,
|
|
) -> Result<ReviewCommentResponse, AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
|
|
|
|
let comment =
|
|
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
|
|
|
|
// Permission: author OR admin/owner
|
|
let is_author = comment.author == 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_author && !is_admin {
|
|
return Err(AppError::NoPower);
|
|
}
|
|
|
|
let mut active: pull_request_review_comment::ActiveModel = comment.clone().into();
|
|
active.body = Set(request.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.author)
|
|
.one(&self.db)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.map(|u| u.username);
|
|
|
|
Ok(ReviewCommentResponse {
|
|
author_username: username,
|
|
..ReviewCommentResponse::from(model)
|
|
})
|
|
}
|
|
|
|
pub async fn review_comment_delete(
|
|
&self,
|
|
namespace: String,
|
|
repo_name: String,
|
|
pr_number: i64,
|
|
comment_id: i64,
|
|
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 comment =
|
|
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
|
|
|
|
// Permission: author OR admin/owner
|
|
let is_author = comment.author == 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_author && !is_admin {
|
|
return Err(AppError::NoPower);
|
|
}
|
|
|
|
pull_request_review_comment::Entity::delete_by_id((repo.id, pr_number, comment_id))
|
|
.exec(&self.db)
|
|
.await?;
|
|
|
|
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Mark a review comment (root of a thread) as resolved.
|
|
pub async fn review_comment_resolve(
|
|
&self,
|
|
namespace: String,
|
|
repo_name: String,
|
|
pr_number: i64,
|
|
comment_id: i64,
|
|
ctx: &Session,
|
|
) -> Result<ReviewCommentResponse, AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
|
|
|
|
let comment =
|
|
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
|
|
|
|
self.check_comment_permission(&repo, &comment, user_uid)
|
|
.await?;
|
|
|
|
let mut active: pull_request_review_comment::ActiveModel = comment.clone().into();
|
|
active.resolved = Set(true);
|
|
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.author)
|
|
.one(&self.db)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.map(|u| u.username);
|
|
|
|
Ok(ReviewCommentResponse {
|
|
author_username: username,
|
|
..ReviewCommentResponse::from(model)
|
|
})
|
|
}
|
|
|
|
/// Mark a review comment thread as unresolved.
|
|
pub async fn review_comment_unresolve(
|
|
&self,
|
|
namespace: String,
|
|
repo_name: String,
|
|
pr_number: i64,
|
|
comment_id: i64,
|
|
ctx: &Session,
|
|
) -> Result<ReviewCommentResponse, AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
|
|
|
|
let comment =
|
|
pull_request_review_comment::Entity::find_by_id((repo.id, pr_number, comment_id))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("Comment not found".to_string()))?;
|
|
|
|
self.check_comment_permission(&repo, &comment, user_uid)
|
|
.await?;
|
|
|
|
let mut active: pull_request_review_comment::ActiveModel = comment.clone().into();
|
|
active.resolved = Set(false);
|
|
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.author)
|
|
.one(&self.db)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.map(|u| u.username);
|
|
|
|
Ok(ReviewCommentResponse {
|
|
author_username: username,
|
|
..ReviewCommentResponse::from(model)
|
|
})
|
|
}
|
|
|
|
/// Reply to an existing review comment, creating a threaded reply.
|
|
pub async fn review_comment_reply(
|
|
&self,
|
|
namespace: String,
|
|
repo_name: String,
|
|
pr_number: i64,
|
|
parent_comment_id: i64,
|
|
request: ReviewCommentReplyRequest,
|
|
ctx: &Session,
|
|
) -> Result<ReviewCommentResponse, AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
|
|
|
|
// Verify PR exists and is not merged
|
|
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 == models::pull_request::PrStatus::Merged.to_string() {
|
|
return Err(AppError::BadRequest(
|
|
"Cannot comment on a merged pull request".to_string(),
|
|
));
|
|
}
|
|
|
|
// Verify parent comment exists
|
|
let parent = pull_request_review_comment::Entity::find_by_id((
|
|
repo.id,
|
|
pr_number,
|
|
parent_comment_id,
|
|
))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("Parent comment not found".to_string()))?;
|
|
|
|
// Get next comment id
|
|
let max_id: Option<Option<i64>> = pull_request_review_comment::Entity::find()
|
|
.filter(pull_request_review_comment::Column::Repo.eq(repo.id))
|
|
.filter(pull_request_review_comment::Column::Number.eq(pr_number))
|
|
.select_only()
|
|
.column_as(pull_request_review_comment::Column::Id.max(), "max_id")
|
|
.into_tuple::<Option<i64>>()
|
|
.one(&self.db)
|
|
.await?;
|
|
let comment_id = max_id.flatten().unwrap_or(0) + 1;
|
|
|
|
let now = Utc::now();
|
|
let active = pull_request_review_comment::ActiveModel {
|
|
repo: Set(repo.id),
|
|
number: Set(pr_number),
|
|
id: Set(comment_id),
|
|
review: Set(None),
|
|
path: Set(parent.path.clone()),
|
|
side: Set(parent.side.clone()),
|
|
line: Set(parent.line),
|
|
old_line: Set(parent.old_line),
|
|
body: Set(request.body),
|
|
author: Set(user_uid),
|
|
resolved: Set(false),
|
|
in_reply_to: Set(Some(parent.id)),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
};
|
|
let model = 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);
|
|
|
|
Ok(ReviewCommentResponse {
|
|
author_username: username,
|
|
..ReviewCommentResponse::from(model)
|
|
})
|
|
}
|
|
|
|
async fn check_comment_permission(
|
|
&self,
|
|
repo: &models::repos::repo::Model,
|
|
comment: &pull_request_review_comment::Model,
|
|
user_uid: Uuid,
|
|
) -> Result<(), AppError> {
|
|
// Authors can always modify their own comments
|
|
if comment.author == user_uid {
|
|
return Ok(());
|
|
}
|
|
|
|
// Admins/owners can modify any comment
|
|
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_admin {
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::NoPower)
|
|
}
|
|
}
|
|
}
|