390 lines
16 KiB
Rust
390 lines
16 KiB
Rust
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<i32>,
|
|
pub require_status_checks: Option<bool>,
|
|
pub required_status_checks: Option<Vec<String>>,
|
|
pub require_linear_history: Option<bool>,
|
|
pub allow_force_pushes: Option<bool>,
|
|
pub allow_deletions: Option<bool>,
|
|
pub require_signed_commits: Option<bool>,
|
|
pub require_code_owner_review: Option<bool>,
|
|
pub dismiss_stale_reviews: Option<bool>,
|
|
pub restrict_pushes: Option<bool>,
|
|
pub push_allowances: Option<Vec<Uuid>>,
|
|
pub restrict_review_dismissal: Option<bool>,
|
|
pub dismissal_allowances: Option<Vec<Uuid>>,
|
|
pub require_conversation_resolution: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
|
pub struct UpdateProtectionRuleParams {
|
|
pub require_approvals: Option<i32>,
|
|
pub require_status_checks: Option<bool>,
|
|
pub required_status_checks: Option<Vec<String>>,
|
|
pub require_linear_history: Option<bool>,
|
|
pub allow_force_pushes: Option<bool>,
|
|
pub allow_deletions: Option<bool>,
|
|
pub require_signed_commits: Option<bool>,
|
|
pub require_code_owner_review: Option<bool>,
|
|
pub dismiss_stale_reviews: Option<bool>,
|
|
pub restrict_pushes: Option<bool>,
|
|
pub push_allowances: Option<Vec<Uuid>>,
|
|
pub restrict_review_dismissal: Option<bool>,
|
|
pub dismissal_allowances: Option<Vec<Uuid>>,
|
|
pub require_conversation_resolution: Option<bool>,
|
|
}
|
|
|
|
impl RepoService {
|
|
pub async fn repo_protection_rules(
|
|
&self,
|
|
ctx: &Session,
|
|
wk_name: &str,
|
|
repo_name: &str,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> Result<Vec<BranchProtectionRule>, 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<BranchProtectionRule, 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?;
|
|
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<Option<BranchProtectionRule>, 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<BranchProtectionRule, 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 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<BranchProtectionRule, 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 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<BranchMergeCheck, 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 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<String> = 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<String>,
|
|
}
|
|
|
|
fn glob_match(pattern: &str, text: &str) -> bool {
|
|
if pattern == text {
|
|
return true;
|
|
}
|
|
if pattern == "*" {
|
|
return true;
|
|
}
|
|
|
|
let p: Vec<char> = pattern.chars().collect();
|
|
let t: Vec<char> = 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();
|
|
}
|
|
}
|