use globset::Glob; use models::repo::repo_branch_protection_policy; use models::repo::repo_tag_protection_policy; use models::repo::repo_tag_protection_whitelists; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; use crate::service::GitService; use crate::share::errors::{GitError, is_unique_violation}; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct BranchPolicyDto { pub id: Uuid, pub repo_id: Uuid, pub branch_pattern: String, pub enable_push: bool, pub enable_push_whitelist: bool, pub push_whitelist_deploy_keys: bool, pub enable_merge_whitelist: bool, pub required_approvals: i32, pub enable_approvals_whitelist: bool, pub block_on_rejected_reviews: bool, pub block_on_change_requested_reviews: bool, pub block_on_outdated_branch: bool, pub dismiss_stale_approvals: bool, pub require_signed_commits: bool, pub protected_file_patterns: String, pub unprotected_file_patterns: String, pub enable_force_push: bool, pub required_status_contexts: String, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl From for BranchPolicyDto { fn from(m: repo_branch_protection_policy::Model) -> Self { Self { id: m.id, repo_id: m.repo_id, branch_pattern: m.branch_pattern, enable_push: m.enable_push, enable_push_whitelist: m.enable_push_whitelist, push_whitelist_deploy_keys: m.push_whitelist_deploy_keys, enable_merge_whitelist: m.enable_merge_whitelist, required_approvals: m.required_approvals, enable_approvals_whitelist: m.enable_approvals_whitelist, block_on_rejected_reviews: m.block_on_rejected_reviews, block_on_change_requested_reviews: m.block_on_change_requested_reviews, block_on_outdated_branch: m.block_on_outdated_branch, dismiss_stale_approvals: m.dismiss_stale_approvals, require_signed_commits: m.require_signed_commits, protected_file_patterns: m.protected_file_patterns, unprotected_file_patterns: m.unprotected_file_patterns, enable_force_push: m.enable_force_push, required_status_contexts: m.required_status_contexts, created_at: m.created_at, updated_at: m.updated_at, } } } #[derive(Debug, Clone, Deserialize, Default, utoipa::ToSchema)] pub struct BranchPolicyFlags { #[serde(default)] pub enable_push: Option, #[serde(default)] pub enable_push_whitelist: Option, #[serde(default)] pub enable_merge_whitelist: Option, #[serde(default)] pub required_approvals: Option, #[serde(default)] pub enable_approvals_whitelist: Option, #[serde(default)] pub block_on_rejected_reviews: Option, #[serde(default)] pub block_on_change_requested_reviews: Option, #[serde(default)] pub block_on_outdated_branch: Option, #[serde(default)] pub dismiss_stale_approvals: Option, #[serde(default)] pub require_signed_commits: Option, #[serde(default)] pub protected_file_patterns: Option, #[serde(default)] pub unprotected_file_patterns: Option, #[serde(default)] pub enable_force_push: Option, #[serde(default)] pub required_status_contexts: Option, } /// 分支保护策略的创建请求。 /// /// 字段与 [`BranchPolicyFlags`] 一一对应,但刻意不使用 `#[serde(flatten)]`: /// 扁平化会让 utoipa 生成 `allOf` 组合,而 web 端 API 生成器无法处理该形状。 #[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)] pub struct CreateBranchPolicyCmd { pub branch_pattern: String, #[serde(default)] pub enable_push: Option, #[serde(default)] pub enable_push_whitelist: Option, #[serde(default)] pub enable_merge_whitelist: Option, #[serde(default)] pub required_approvals: Option, #[serde(default)] pub enable_approvals_whitelist: Option, #[serde(default)] pub block_on_rejected_reviews: Option, #[serde(default)] pub block_on_change_requested_reviews: Option, #[serde(default)] pub block_on_outdated_branch: Option, #[serde(default)] pub dismiss_stale_approvals: Option, #[serde(default)] pub require_signed_commits: Option, #[serde(default)] pub protected_file_patterns: Option, #[serde(default)] pub unprotected_file_patterns: Option, #[serde(default)] pub enable_force_push: Option, #[serde(default)] pub required_status_contexts: Option, } impl CreateBranchPolicyCmd { /// 取出策略标志位视图,供既有业务逻辑复用。 pub fn flags(&self) -> BranchPolicyFlags { BranchPolicyFlags { enable_push: self.enable_push, enable_push_whitelist: self.enable_push_whitelist, enable_merge_whitelist: self.enable_merge_whitelist, required_approvals: self.required_approvals, enable_approvals_whitelist: self.enable_approvals_whitelist, block_on_rejected_reviews: self.block_on_rejected_reviews, block_on_change_requested_reviews: self.block_on_change_requested_reviews, block_on_outdated_branch: self.block_on_outdated_branch, dismiss_stale_approvals: self.dismiss_stale_approvals, require_signed_commits: self.require_signed_commits, protected_file_patterns: self.protected_file_patterns.clone(), unprotected_file_patterns: self.unprotected_file_patterns.clone(), enable_force_push: self.enable_force_push, required_status_contexts: self.required_status_contexts.clone(), } } } /// 分支保护策略的更新请求(三态字段语义与其它 PATCH 一致)。 #[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)] pub struct UpdateBranchPolicyCmd { #[serde(default)] pub branch_pattern: Option, #[serde(default)] pub enable_push: Option, #[serde(default)] pub enable_push_whitelist: Option, #[serde(default)] pub enable_merge_whitelist: Option, #[serde(default)] pub required_approvals: Option, #[serde(default)] pub enable_approvals_whitelist: Option, #[serde(default)] pub block_on_rejected_reviews: Option, #[serde(default)] pub block_on_change_requested_reviews: Option, #[serde(default)] pub block_on_outdated_branch: Option, #[serde(default)] pub dismiss_stale_approvals: Option, #[serde(default)] pub require_signed_commits: Option, #[serde(default)] pub protected_file_patterns: Option, #[serde(default)] pub unprotected_file_patterns: Option, #[serde(default)] pub enable_force_push: Option, #[serde(default)] pub required_status_contexts: Option, } impl UpdateBranchPolicyCmd { pub fn flags(&self) -> BranchPolicyFlags { BranchPolicyFlags { enable_push: self.enable_push, enable_push_whitelist: self.enable_push_whitelist, enable_merge_whitelist: self.enable_merge_whitelist, required_approvals: self.required_approvals, enable_approvals_whitelist: self.enable_approvals_whitelist, block_on_rejected_reviews: self.block_on_rejected_reviews, block_on_change_requested_reviews: self.block_on_change_requested_reviews, block_on_outdated_branch: self.block_on_outdated_branch, dismiss_stale_approvals: self.dismiss_stale_approvals, require_signed_commits: self.require_signed_commits, protected_file_patterns: self.protected_file_patterns.clone(), unprotected_file_patterns: self.unprotected_file_patterns.clone(), enable_force_push: self.enable_force_push, required_status_contexts: self.required_status_contexts.clone(), } } } fn validate_pattern(pattern: &str) -> Result<(), GitError> { let trimmed = pattern.trim(); if trimmed.is_empty() || trimmed.len() > 255 || trimmed.chars().any(|c| c.is_control()) { return Err(GitError::InvalidArgument( "branch_pattern must be 1-255 printable characters".to_string(), )); } Glob::new(trimmed) .map_err(|e| GitError::InvalidArgument(format!("invalid branch pattern: {e}")))?; Ok(()) } fn validate_approvals(count: i32) -> Result<(), GitError> { if !(0..=10).contains(&count) { return Err(GitError::InvalidArgument( "required_approvals must be between 0 and 10".to_string(), )); } Ok(()) } fn validate_patterns(raw: &str) -> Result<(), GitError> { for part in raw.split([',', '\n']) { let trimmed = part.trim(); if trimmed.is_empty() { continue; } Glob::new(trimmed) .map_err(|e| GitError::InvalidArgument(format!("invalid file pattern: {e}")))?; } Ok(()) } impl GitService { pub async fn list_branch_policies( &self, space_name: String, repo_name: String, ) -> Result, GitError> { let ctx = self.context(space_name, repo_name).await?; let rows = repo_branch_protection_policy::Entity::find() .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id)) .order_by_asc(repo_branch_protection_policy::Column::BranchPattern) .all(self.db_read()) .await?; Ok(rows.into_iter().map(BranchPolicyDto::from).collect()) } pub async fn get_branch_policy( &self, space_name: String, repo_name: String, policy_id: Uuid, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let row = repo_branch_protection_policy::Entity::find_by_id(policy_id) .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id)) .one(self.db_read()) .await? .ok_or(GitError::NotFound)?; Ok(BranchPolicyDto::from(row)) } pub async fn create_branch_policy( &self, space_name: String, repo_name: String, cmd: CreateBranchPolicyCmd, ) -> Result { let ctx = self.context(space_name, repo_name).await?; validate_pattern(&cmd.branch_pattern)?; let flags = cmd.flags(); let required = flags.required_approvals.unwrap_or(0); validate_approvals(required)?; let protected = flags.protected_file_patterns.unwrap_or_default(); let unprotected = flags.unprotected_file_patterns.unwrap_or_default(); validate_patterns(&protected)?; validate_patterns(&unprotected)?; let now = OffsetDateTime::now_utc(); let row = repo_branch_protection_policy::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(ctx.model.id), branch_pattern: Set(cmd.branch_pattern.trim().to_string()), enable_push: Set(flags.enable_push.unwrap_or(true)), enable_push_whitelist: Set(flags.enable_push_whitelist.unwrap_or(false)), push_whitelist_deploy_keys: Set(false), enable_merge_whitelist: Set(flags.enable_merge_whitelist.unwrap_or(false)), required_approvals: Set(required), enable_approvals_whitelist: Set(flags.enable_approvals_whitelist.unwrap_or(false)), block_on_rejected_reviews: Set(flags.block_on_rejected_reviews.unwrap_or(true)), block_on_change_requested_reviews: Set(flags .block_on_change_requested_reviews .unwrap_or(true)), block_on_outdated_branch: Set(flags.block_on_outdated_branch.unwrap_or(false)), dismiss_stale_approvals: Set(flags.dismiss_stale_approvals.unwrap_or(true)), require_signed_commits: Set(flags.require_signed_commits.unwrap_or(false)), protected_file_patterns: Set(protected), unprotected_file_patterns: Set(unprotected), enable_force_push: Set(flags.enable_force_push.unwrap_or(false)), required_status_contexts: Set(flags.required_status_contexts.unwrap_or_default()), created_at: Set(now), updated_at: Set(now), ..Default::default() }; let created = match row.insert(self.db_write()).await { Ok(created) => created, Err(error) if is_unique_violation(&error) => { return Err(GitError::Conflict( "a protection policy for this pattern already exists".to_string(), )); } Err(error) => return Err(error.into()), }; Ok(BranchPolicyDto::from(created)) } pub async fn update_branch_policy( &self, space_name: String, repo_name: String, policy_id: Uuid, cmd: UpdateBranchPolicyCmd, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let row = repo_branch_protection_policy::Entity::find_by_id(policy_id) .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id)) .one(self.db_read()) .await? .ok_or(GitError::NotFound)?; let flags = cmd.flags(); let mut model: repo_branch_protection_policy::ActiveModel = row.into(); if let Some(pattern) = cmd.branch_pattern.as_deref() { validate_pattern(pattern)?; model.branch_pattern = Set(pattern.trim().to_string()); } if let Some(count) = flags.required_approvals { validate_approvals(count)?; model.required_approvals = Set(count); } if let Some(raw) = flags.protected_file_patterns.as_deref() { validate_patterns(raw)?; model.protected_file_patterns = Set(raw.to_string()); } if let Some(raw) = flags.unprotected_file_patterns.as_deref() { validate_patterns(raw)?; model.unprotected_file_patterns = Set(raw.to_string()); } for (flag, value) in [ (&flags.enable_push, &mut model.enable_push), ( &flags.enable_push_whitelist, &mut model.enable_push_whitelist, ), ( &flags.enable_merge_whitelist, &mut model.enable_merge_whitelist, ), ( &flags.enable_approvals_whitelist, &mut model.enable_approvals_whitelist, ), ( &flags.block_on_rejected_reviews, &mut model.block_on_rejected_reviews, ), ( &flags.block_on_change_requested_reviews, &mut model.block_on_change_requested_reviews, ), ( &flags.block_on_outdated_branch, &mut model.block_on_outdated_branch, ), ( &flags.dismiss_stale_approvals, &mut model.dismiss_stale_approvals, ), ( &flags.require_signed_commits, &mut model.require_signed_commits, ), (&flags.enable_force_push, &mut model.enable_force_push), ] { if let Some(value_flag) = flag { *value = Set(*value_flag); } } model.updated_at = Set(OffsetDateTime::now_utc()); let updated = match model.update(self.db_write()).await { Ok(updated) => updated, Err(error) if is_unique_violation(&error) => { return Err(GitError::Conflict( "a protection policy for this pattern already exists".to_string(), )); } Err(error) => return Err(error.into()), }; Ok(BranchPolicyDto::from(updated)) } pub async fn delete_branch_policy( &self, space_name: String, repo_name: String, policy_id: Uuid, ) -> Result<(), GitError> { let ctx = self.context(space_name, repo_name).await?; let deleted = repo_branch_protection_policy::Entity::delete_many() .filter(repo_branch_protection_policy::Column::Id.eq(policy_id)) .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id)) .exec(self.db_write()) .await?; if deleted.rows_affected == 0 { return Err(GitError::NotFound); } Ok(()) } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct TagPolicyDto { pub id: Uuid, pub repo_id: Uuid, pub name_pattern: String, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl From for TagPolicyDto { fn from(m: repo_tag_protection_policy::Model) -> Self { Self { id: m.id, repo_id: m.repo_id, name_pattern: m.name_pattern, created_at: m.created_at, updated_at: m.updated_at, } } } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateTagPolicyCmd { pub name_pattern: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateTagPolicyCmd { #[serde(default)] pub name_pattern: Option, } fn validate_tag_pattern(pattern: &str) -> Result<(), GitError> { let trimmed = pattern.trim(); if trimmed.is_empty() || trimmed.len() > 255 || trimmed.chars().any(|c| c.is_control()) { return Err(GitError::InvalidArgument( "name_pattern must be 1-255 printable characters".to_string(), )); } Glob::new(trimmed) .map_err(|e| GitError::InvalidArgument(format!("invalid tag pattern: {e}")))?; Ok(()) } impl GitService { pub async fn list_tag_policies( &self, space_name: String, repo_name: String, ) -> Result, GitError> { let ctx = self.context(space_name, repo_name).await?; let rows = repo_tag_protection_policy::Entity::find() .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id)) .order_by_asc(repo_tag_protection_policy::Column::NamePattern) .all(self.db_read()) .await?; Ok(rows.into_iter().map(TagPolicyDto::from).collect()) } pub async fn get_tag_policy( &self, space_name: String, repo_name: String, policy_id: Uuid, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let row = repo_tag_protection_policy::Entity::find_by_id(policy_id) .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id)) .one(self.db_read()) .await? .ok_or(GitError::NotFound)?; Ok(TagPolicyDto::from(row)) } pub async fn create_tag_policy( &self, space_name: String, repo_name: String, cmd: CreateTagPolicyCmd, ) -> Result { let ctx = self.context(space_name, repo_name).await?; validate_tag_pattern(&cmd.name_pattern)?; let now = OffsetDateTime::now_utc(); let row = repo_tag_protection_policy::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(ctx.model.id), name_pattern: Set(cmd.name_pattern.trim().to_string()), created_at: Set(now), updated_at: Set(now), ..Default::default() }; let created = match row.insert(self.db_write()).await { Ok(created) => created, Err(error) if is_unique_violation(&error) => { return Err(GitError::Conflict( "a tag protection policy for this pattern already exists".to_string(), )); } Err(error) => return Err(error.into()), }; Ok(TagPolicyDto::from(created)) } pub async fn update_tag_policy( &self, space_name: String, repo_name: String, policy_id: Uuid, cmd: UpdateTagPolicyCmd, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let row = repo_tag_protection_policy::Entity::find_by_id(policy_id) .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id)) .one(self.db_read()) .await? .ok_or(GitError::NotFound)?; let mut model: repo_tag_protection_policy::ActiveModel = row.into(); if let Some(pattern) = cmd.name_pattern.as_deref() { validate_tag_pattern(pattern)?; model.name_pattern = Set(pattern.trim().to_string()); } model.updated_at = Set(OffsetDateTime::now_utc()); let updated = match model.update(self.db_write()).await { Ok(updated) => updated, Err(error) if is_unique_violation(&error) => { return Err(GitError::Conflict( "a tag protection policy for this pattern already exists".to_string(), )); } Err(error) => return Err(error.into()), }; Ok(TagPolicyDto::from(updated)) } pub async fn delete_tag_policy( &self, space_name: String, repo_name: String, policy_id: Uuid, ) -> Result<(), GitError> { let ctx = self.context(space_name, repo_name).await?; repo_tag_protection_whitelists::Entity::delete_many() .filter(repo_tag_protection_whitelists::Column::PolicyId.eq(policy_id)) .exec(self.db_write()) .await?; let deleted = repo_tag_protection_policy::Entity::delete_many() .filter(repo_tag_protection_policy::Column::Id.eq(policy_id)) .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id)) .exec(self.db_write()) .await?; if deleted.rows_affected == 0 { return Err(GitError::NotFound); } Ok(()) } }