use chrono::Utc; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; use crate::models::repos::BranchProtectionRule; use crate::service::RepoService; use crate::session::Session; use super::util::{clamp_limit_offset, ensure_affected, required_text}; #[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct CreateProtectionRuleParams { pub pattern: String, pub require_approvals: Option, pub require_status_checks: Option, pub required_status_checks: Option>, pub require_linear_history: Option, pub allow_force_pushes: Option, pub allow_deletions: Option, pub require_signed_commits: Option, pub require_code_owner_review: Option, pub dismiss_stale_reviews: Option, pub restrict_pushes: Option, pub push_allowances: Option>, pub restrict_review_dismissal: Option, pub dismissal_allowances: Option>, pub require_conversation_resolution: Option, } #[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct UpdateProtectionRuleParams { pub require_approvals: Option, pub require_status_checks: Option, pub required_status_checks: Option>, pub require_linear_history: Option, pub allow_force_pushes: Option, pub allow_deletions: Option, pub require_signed_commits: Option, pub require_code_owner_review: Option, pub dismiss_stale_reviews: Option, pub restrict_pushes: Option, pub push_allowances: Option>, pub restrict_review_dismissal: Option, pub dismissal_allowances: Option>, pub require_conversation_resolution: Option, } impl RepoService { pub async fn repo_protection_rules( &self, ctx: &Session, wk_name: &str, repo_name: &str, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; let (limit, offset) = clamp_limit_offset(limit, offset); sqlx::query_as::<_, BranchProtectionRule>( "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ require_conversation_resolution, created_by, created_at, updated_at \ FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC LIMIT $2 OFFSET $3", ) .bind(repo.id).bind(limit).bind(offset) .fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database) } pub async fn repo_get_protection_rule( &self, ctx: &Session, wk_name: &str, repo_name: &str, rule_id: Uuid, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; sqlx::query_as::<_, BranchProtectionRule>( "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ require_conversation_resolution, created_by, created_at, updated_at \ FROM branch_protection_rule WHERE id = $1 AND repo_id = $2", ) .bind(rule_id) .bind(repo.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("protection rule not found".into())) } pub async fn repo_match_protection( &self, ctx: &Session, wk_name: &str, repo_name: &str, branch_name: &str, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; let rules = sqlx::query_as::<_, BranchProtectionRule>( "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ require_conversation_resolution, created_by, created_at, updated_at \ FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC", ) .bind(repo.id) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(rules .into_iter() .find(|r| glob_match(&r.pattern, branch_name))) } pub async fn repo_create_protection_rule( &self, ctx: &Session, wk_name: &str, repo_name: &str, params: CreateProtectionRuleParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) .await?; let pattern = required_text(params.pattern, "pattern")?; let now = Utc::now(); let rule_id = Uuid::now_v7(); let required_checks = params.required_status_checks.unwrap_or_default(); let push_allow = params.push_allowances.unwrap_or_default(); let dismiss_allow = params.dismissal_allowances.unwrap_or_default(); sqlx::query( "INSERT INTO branch_protection_rule (id, repo_id, pattern, require_approvals, \ require_status_checks, required_status_checks, require_linear_history, allow_force_pushes, \ allow_deletions, require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ require_conversation_resolution, created_by, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19)", ) .bind(rule_id).bind(repo.id).bind(&pattern) .bind(params.require_approvals.unwrap_or(0)) .bind(params.require_status_checks.unwrap_or(false)) .bind(&required_checks) .bind(params.require_linear_history.unwrap_or(false)) .bind(params.allow_force_pushes.unwrap_or(false)) .bind(params.allow_deletions.unwrap_or(false)) .bind(params.require_signed_commits.unwrap_or(false)) .bind(params.require_code_owner_review.unwrap_or(false)) .bind(params.dismiss_stale_reviews.unwrap_or(false)) .bind(params.restrict_pushes.unwrap_or(false)) .bind(&push_allow) .bind(params.restrict_review_dismissal.unwrap_or(false)) .bind(&dismiss_allow) .bind(params.require_conversation_resolution.unwrap_or(false)) .bind(user_uid).bind(now) .execute(self.ctx.db.writer()).await.map_err(AppError::Database)?; sqlx::query_as::<_, BranchProtectionRule>( "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ require_conversation_resolution, created_by, created_at, updated_at \ FROM branch_protection_rule WHERE id = $1", ) .bind(rule_id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn repo_update_protection_rule( &self, ctx: &Session, wk_name: &str, repo_name: &str, rule_id: Uuid, params: UpdateProtectionRuleParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) .await?; let existing = sqlx::query_as::<_, BranchProtectionRule>( "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ require_conversation_resolution, created_by, created_at, updated_at \ FROM branch_protection_rule WHERE id = $1 AND repo_id = $2", ) .bind(rule_id) .bind(repo.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("protection rule not found".into()))?; let now = Utc::now(); sqlx::query( "UPDATE branch_protection_rule SET \ require_approvals = $1, require_status_checks = $2, required_status_checks = $3, \ require_linear_history = $4, allow_force_pushes = $5, allow_deletions = $6, \ require_signed_commits = $7, require_code_owner_review = $8, dismiss_stale_reviews = $9, \ restrict_pushes = $10, push_allowances = $11, restrict_review_dismissal = $12, \ dismissal_allowances = $13, require_conversation_resolution = $14, updated_at = $15 \ WHERE id = $16", ) .bind(params.require_approvals.unwrap_or(existing.require_approvals)) .bind(params.require_status_checks.unwrap_or(existing.require_status_checks)) .bind(params.required_status_checks.as_ref().unwrap_or(&existing.required_status_checks)) .bind(params.require_linear_history.unwrap_or(existing.require_linear_history)) .bind(params.allow_force_pushes.unwrap_or(existing.allow_force_pushes)) .bind(params.allow_deletions.unwrap_or(existing.allow_deletions)) .bind(params.require_signed_commits.unwrap_or(existing.require_signed_commits)) .bind(params.require_code_owner_review.unwrap_or(existing.require_code_owner_review)) .bind(params.dismiss_stale_reviews.unwrap_or(existing.dismiss_stale_reviews)) .bind(params.restrict_pushes.unwrap_or(existing.restrict_pushes)) .bind(params.push_allowances.as_ref().unwrap_or(&existing.push_allowances)) .bind(params.restrict_review_dismissal.unwrap_or(existing.restrict_review_dismissal)) .bind(params.dismissal_allowances.as_ref().unwrap_or(&existing.dismissal_allowances)) .bind(params.require_conversation_resolution.unwrap_or(existing.require_conversation_resolution)) .bind(now).bind(rule_id) .execute(self.ctx.db.writer()).await.map_err(AppError::Database)?; sqlx::query_as::<_, BranchProtectionRule>( "SELECT id, repo_id, pattern, require_approvals, require_status_checks, \ required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \ require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \ restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \ require_conversation_resolution, created_by, created_at, updated_at \ FROM branch_protection_rule WHERE id = $1", ) .bind(rule_id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn repo_delete_protection_rule( &self, ctx: &Session, wk_name: &str, repo_name: &str, rule_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) .await?; let result = sqlx::query("DELETE FROM branch_protection_rule WHERE id = $1 AND repo_id = $2") .bind(rule_id) .bind(repo.id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "protection rule not found") } pub async fn repo_check_branch_merge_allowed( &self, ctx: &Session, wk_name: &str, repo_name: &str, target_branch: &str, pr_number: i64, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; let rule = self .repo_match_protection(ctx, wk_name, repo_name, target_branch) .await?; let Some(rule) = rule else { return Ok(BranchMergeCheck { allowed: true, reasons: vec![], }); }; let mut reasons = Vec::new(); if rule.require_approvals > 0 { let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM pr_review r \ JOIN pull_request pr ON pr.id = r.pull_request_id \ WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \ AND r.state = 'approved' AND r.dismissed_at IS NULL AND r.submitted_at IS NOT NULL", ) .bind(repo.id).bind(pr_number) .fetch_one(self.ctx.db.reader()).await.map_err(AppError::Database)?; if count < rule.require_approvals as i64 { reasons.push(format!( "requires {} approvals, has {}", rule.require_approvals, count )); } } if rule.require_status_checks && !rule.required_status_checks.is_empty() { let passed: Vec = sqlx::query_scalar( "SELECT DISTINCT cr.context FROM repo_commit_status cr \ JOIN pull_request pr ON pr.head_commit_sha = cr.latest_commit_sha \ WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \ AND cr.state = 'success'", ) .bind(repo.id) .bind(pr_number) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; for required in &rule.required_status_checks { if !passed.contains(required) { reasons.push(format!("required check '{}' has not passed", required)); } } } Ok(BranchMergeCheck { allowed: reasons.is_empty(), reasons, }) } } #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct BranchMergeCheck { pub allowed: bool, pub reasons: Vec, } fn glob_match(pattern: &str, text: &str) -> bool { if pattern == text { return true; } if pattern == "*" { return true; } let p: Vec = pattern.chars().collect(); let t: Vec = text.chars().collect(); let (mut pi, mut ti) = (0usize, 0usize); let (mut star_pi, mut star_ti) = (None, None); loop { if pi < p.len() && ti < t.len() && (p[pi] == '?' || p[pi] == t[ti]) { pi += 1; ti += 1; continue; } if pi < p.len() && p[pi] == '*' { star_pi = Some(pi); star_ti = Some(ti); pi += 1; continue; } if let (Some(sp), Some(st)) = (star_pi, star_ti) && st < t.len() { pi = sp + 1; let nt = st + 1; star_ti = Some(nt); ti = nt; continue; } return pi == p.len() && ti == t.len(); } }