Skip to content
Clone
e1df092
584 lines 22.1 KB Rust
use globset::Glob;
use models::repo::{
repo_branch_protection_policy, repo_tag_protection_policy, repo_tag_protection_whitelists,
};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, 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<repo_branch_protection_policy::Model> 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<bool>,
#[serde(default)]
pub enable_push_whitelist: Option<bool>,
#[serde(default)]
pub enable_merge_whitelist: Option<bool>,
#[serde(default)]
pub required_approvals: Option<i32>,
#[serde(default)]
pub enable_approvals_whitelist: Option<bool>,
#[serde(default)]
pub block_on_rejected_reviews: Option<bool>,
#[serde(default)]
pub block_on_change_requested_reviews: Option<bool>,
#[serde(default)]
pub block_on_outdated_branch: Option<bool>,
#[serde(default)]
pub dismiss_stale_approvals: Option<bool>,
#[serde(default)]
pub require_signed_commits: Option<bool>,
#[serde(default)]
pub protected_file_patterns: Option<String>,
#[serde(default)]
pub unprotected_file_patterns: Option<String>,
#[serde(default)]
pub enable_force_push: Option<bool>,
#[serde(default)]
pub required_status_contexts: Option<String>,
}
/// 分支保护策略的创建请求。
///
/// 字段与 [`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<bool>,
#[serde(default)]
pub enable_push_whitelist: Option<bool>,
#[serde(default)]
pub enable_merge_whitelist: Option<bool>,
#[serde(default)]
pub required_approvals: Option<i32>,
#[serde(default)]
pub enable_approvals_whitelist: Option<bool>,
#[serde(default)]
pub block_on_rejected_reviews: Option<bool>,
#[serde(default)]
pub block_on_change_requested_reviews: Option<bool>,
#[serde(default)]
pub block_on_outdated_branch: Option<bool>,
#[serde(default)]
pub dismiss_stale_approvals: Option<bool>,
#[serde(default)]
pub require_signed_commits: Option<bool>,
#[serde(default)]
pub protected_file_patterns: Option<String>,
#[serde(default)]
pub unprotected_file_patterns: Option<String>,
#[serde(default)]
pub enable_force_push: Option<bool>,
#[serde(default)]
pub required_status_contexts: Option<String>,
}
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<String>,
#[serde(default)]
pub enable_push: Option<bool>,
#[serde(default)]
pub enable_push_whitelist: Option<bool>,
#[serde(default)]
pub enable_merge_whitelist: Option<bool>,
#[serde(default)]
pub required_approvals: Option<i32>,
#[serde(default)]
pub enable_approvals_whitelist: Option<bool>,
#[serde(default)]
pub block_on_rejected_reviews: Option<bool>,
#[serde(default)]
pub block_on_change_requested_reviews: Option<bool>,
#[serde(default)]
pub block_on_outdated_branch: Option<bool>,
#[serde(default)]
pub dismiss_stale_approvals: Option<bool>,
#[serde(default)]
pub require_signed_commits: Option<bool>,
#[serde(default)]
pub protected_file_patterns: Option<String>,
#[serde(default)]
pub unprotected_file_patterns: Option<String>,
#[serde(default)]
pub enable_force_push: Option<bool>,
#[serde(default)]
pub required_status_contexts: Option<String>,
}
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<Vec<BranchPolicyDto>, 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<BranchPolicyDto, GitError> {
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<BranchPolicyDto, GitError> {
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),
};
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<BranchPolicyDto, GitError> {
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<repo_tag_protection_policy::Model> 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<String>,
}
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<Vec<TagPolicyDto>, 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<TagPolicyDto, GitError> {
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<TagPolicyDto, GitError> {
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),
};
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<TagPolicyDto, GitError> {
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(())
}
}