use crate::AppService; use crate::error::AppError; use models::pull_request::{self as pr_module, ReviewState, pull_request_review}; use models::repos::repo_branch_protect; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct BranchProtectionResponse { pub id: i64, pub repo: Uuid, pub branch: String, pub forbid_push: bool, pub forbid_pull: bool, pub forbid_merge: bool, pub forbid_deletion: bool, pub forbid_force_push: bool, pub forbid_tag_push: bool, pub required_approvals: i32, pub dismiss_stale_reviews: bool, pub require_linear_history: bool, pub allow_fork_syncing: bool, } impl From for BranchProtectionResponse { fn from(m: repo_branch_protect::Model) -> Self { Self { id: m.id, repo: m.repo, branch: m.branch, forbid_push: m.forbid_push, forbid_pull: m.forbid_pull, forbid_merge: m.forbid_merge, forbid_deletion: m.forbid_deletion, forbid_force_push: m.forbid_force_push, forbid_tag_push: m.forbid_tag_push, required_approvals: m.required_approvals, dismiss_stale_reviews: m.dismiss_stale_reviews, require_linear_history: m.require_linear_history, allow_fork_syncing: m.allow_fork_syncing, } } } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchProtectionCreateRequest { pub branch: String, #[serde(default)] pub forbid_push: bool, #[serde(default)] pub forbid_pull: bool, #[serde(default)] pub forbid_merge: bool, #[serde(default)] pub forbid_deletion: bool, #[serde(default)] pub forbid_force_push: bool, #[serde(default)] pub forbid_tag_push: bool, #[serde(default)] pub required_approvals: i32, #[serde(default)] pub dismiss_stale_reviews: bool, #[serde(default)] pub require_linear_history: bool, #[serde(default = "default_allow_fork_syncing")] pub allow_fork_syncing: bool, } fn default_allow_fork_syncing() -> bool { true } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchProtectionUpdateRequest { pub branch: Option, pub forbid_push: Option, pub forbid_pull: Option, pub forbid_merge: Option, pub forbid_deletion: Option, pub forbid_force_push: Option, pub forbid_tag_push: Option, pub required_approvals: Option, pub dismiss_stale_reviews: Option, pub require_linear_history: Option, pub allow_fork_syncing: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ApprovalCheckResult { pub enough_approvals: bool, pub approvals: i32, pub required: i32, pub reviewers: Vec, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ReviewerInfo { pub reviewer: Uuid, pub state: String, pub submitted_at: Option>, } impl AppService { /// List all branch protection rules for a repository. pub async fn branch_protection_list( &self, namespace: String, repo_name: String, ctx: &Session, ) -> Result, AppError> { let _ = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let rules = repo_branch_protect::Entity::find() .filter(repo_branch_protect::Column::Repo.eq(repo.id)) .all(&self.db) .await?; Ok(rules .into_iter() .map(BranchProtectionResponse::from) .collect()) } /// Get a single branch protection rule by id. pub async fn branch_protection_get( &self, namespace: String, repo_name: String, rule_id: i64, ctx: &Session, ) -> Result { let _ = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let rule = repo_branch_protect::Entity::find_by_id(rule_id) .filter(repo_branch_protect::Column::Repo.eq(repo.id)) .one(&self.db) .await? .ok_or_else(|| AppError::NotFound("Branch protection rule not found".to_string()))?; Ok(BranchProtectionResponse::from(rule)) } /// Create a branch protection rule. pub async fn branch_protection_create( &self, namespace: String, repo_name: String, request: BranchProtectionCreateRequest, ctx: &Session, ) -> Result { let repo = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let active = repo_branch_protect::ActiveModel { id: Default::default(), repo: Set(repo.id), branch: Set(request.branch.clone()), forbid_push: Set(request.forbid_push), forbid_pull: Set(request.forbid_pull), forbid_merge: Set(request.forbid_merge), forbid_deletion: Set(request.forbid_deletion), forbid_force_push: Set(request.forbid_force_push), forbid_tag_push: Set(request.forbid_tag_push), required_approvals: Set(request.required_approvals), dismiss_stale_reviews: Set(request.dismiss_stale_reviews), require_linear_history: Set(request.require_linear_history), allow_fork_syncing: Set(request.allow_fork_syncing), }; let rule = active.insert(&self.db).await?; let _ = self .project_log_activity( repo.project, Some(repo.id), ctx.user().unwrap_or(Uuid::nil()), super::super::project::activity::ActivityLogParams { event_type: "branch_protection_create".to_string(), title: format!( "Branch protection created for '{}' on branch '{}'", repo_name, request.branch ), repo_id: Some(repo.id), content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({ "branch": request.branch })), is_private: false, }, ) .await; Ok(BranchProtectionResponse::from(rule)) } /// Update a branch protection rule. pub async fn branch_protection_update( &self, namespace: String, repo_name: String, rule_id: i64, request: BranchProtectionUpdateRequest, ctx: &Session, ) -> Result { let repo = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let rule = repo_branch_protect::Entity::find_by_id(rule_id) .filter(repo_branch_protect::Column::Repo.eq(repo.id)) .one(&self.db) .await? .ok_or_else(|| AppError::NotFound("Branch protection rule not found".to_string()))?; let mut active: repo_branch_protect::ActiveModel = rule.into(); if let Some(v) = request.branch { active.branch = Set(v); } if let Some(v) = request.forbid_push { active.forbid_push = Set(v); } if let Some(v) = request.forbid_pull { active.forbid_pull = Set(v); } if let Some(v) = request.forbid_merge { active.forbid_merge = Set(v); } if let Some(v) = request.forbid_deletion { active.forbid_deletion = Set(v); } if let Some(v) = request.forbid_force_push { active.forbid_force_push = Set(v); } if let Some(v) = request.forbid_tag_push { active.forbid_tag_push = Set(v); } if let Some(v) = request.required_approvals { active.required_approvals = Set(v); } if let Some(v) = request.dismiss_stale_reviews { active.dismiss_stale_reviews = Set(v); } if let Some(v) = request.require_linear_history { active.require_linear_history = Set(v); } if let Some(v) = request.allow_fork_syncing { active.allow_fork_syncing = Set(v); } let updated = active.update(&self.db).await?; Ok(BranchProtectionResponse::from(updated)) } /// Delete a branch protection rule. pub async fn branch_protection_delete( &self, namespace: String, repo_name: String, rule_id: i64, ctx: &Session, ) -> Result<(), AppError> { let repo = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let deleted = repo_branch_protect::Entity::delete_many() .filter(repo_branch_protect::Column::Id.eq(rule_id)) .filter(repo_branch_protect::Column::Repo.eq(repo.id)) .exec(&self.db) .await?; if deleted.rows_affected == 0 { return Err(AppError::NotFound( "Branch protection rule not found".to_string(), )); } Ok(()) } /// Check approval count for a PR against branch protection required_approvals. pub async fn branch_protection_check_approvals( &self, namespace: String, repo_name: String, pr_number: i64, _ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, _ctx).await?; let pr = pr_module::PullRequest::find() .filter(pr_module::pull_request::Column::Repo.eq(repo.id)) .filter(pr_module::pull_request::Column::Number.eq(pr_number)) .one(&self.db) .await? .ok_or_else(|| AppError::NotFound("Pull request not found".to_string()))?; // Find branch protection for the base branch let protection = repo_branch_protect::Entity::find() .filter(repo_branch_protect::Column::Repo.eq(repo.id)) .filter(repo_branch_protect::Column::Branch.eq(&pr.base)) .one(&self.db) .await?; let required = protection .as_ref() .map(|p| p.required_approvals) .unwrap_or(0); if required <= 0 { return Ok(ApprovalCheckResult { enough_approvals: true, approvals: 0, required, reviewers: vec![], }); } // Count approvals from pull_request_review table 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)) .filter(pull_request_review::Column::State.eq(ReviewState::Approved.to_string())) .all(&self.db) .await?; let approvals = reviews.len() as i32; let reviewers: Vec = reviews .into_iter() .map(|r| ReviewerInfo { reviewer: r.reviewer, state: r.state, submitted_at: r.submitted_at, }) .collect(); Ok(ApprovalCheckResult { enough_approvals: approvals >= required, approvals, required, reviewers, }) } /// Find the branch protection rule for a given repo+branch. pub async fn branch_protection_find( &self, repo_id: Uuid, branch: &str, ) -> Result, AppError> { let rule = repo_branch_protect::Entity::find() .filter(repo_branch_protect::Column::Repo.eq(repo_id)) .filter(repo_branch_protect::Column::Branch.eq(branch)) .one(&self.db) .await?; Ok(rule) } }