feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+389
View File
@@ -0,0 +1,389 @@
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();
}
}