use std::collections::HashMap; use git::context::facade::GitContext; use git::share::policy::{branch_policy_whitelists, match_branch_policies}; use globset::{Glob, GlobSet, GlobSetBuilder}; use models::pull_request::{pull_request_review, pull_request_reviewer, pull_requests}; use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::errors::PullError; use crate::services::changes::PullSummaryDto; use crate::services::service::PullService; const SIGNED_CHECK_CAP: usize = 50; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct MergeGateDto { pub allowed: bool, pub reasons: Vec, pub protected: bool, pub required_approvals: i32, pub approvals: i32, pub change_requests: i32, pub outdated: bool, pub bypassed: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReviewVerdict { Approved, ChangesRequested, Commented, } fn build_glob_set(raw: &str) -> Result, PullError> { let mut builder = GlobSetBuilder::new(); let mut count = 0usize; for line in raw.split([',', '\n']) { let trimmed = line.trim(); if trimmed.is_empty() { continue; } let pattern = if trimmed.contains('/') { trimmed.to_string() } else { format!("**/{trimmed}") }; builder.add( Glob::new(&pattern) .map_err(|e| PullError::Internal(format!("invalid glob `{pattern}`: {e}")))?, ); count += 1; } if count == 0 { return Ok(None); } builder .build() .map(Some) .map_err(|e| PullError::Internal(format!("invalid glob set: {e}"))) } impl PullService { /// 每位审阅者在 PR 上的最新一次非草稿评审结论。 pub async fn latest_verdicts( &self, pull_id: Uuid, head_sha: &str, dismiss_stale: bool, ) -> Result, PullError> { let mut select = pull_request_review::Entity::find() .filter(pull_request_review::Column::PullRequestId.eq(pull_id)) .filter( pull_request_review::Column::State .ne(models::pull_request::pull_request_review::ReviewState::Pending), ) .order_by_asc(pull_request_review::Column::CreatedAt); if dismiss_stale { select = select.filter(pull_request_review::Column::CommitSha.eq(head_sha)); } let rows = select.all(self.db_read()).await?; let mut out: HashMap = HashMap::new(); for row in rows { let verdict = match row.state { models::pull_request::pull_request_review::ReviewState::Approved => { ReviewVerdict::Approved } models::pull_request::pull_request_review::ReviewState::ChangesRequested => { ReviewVerdict::ChangesRequested } _ => ReviewVerdict::Commented, }; out.insert(row.user_id, verdict); } Ok(out) } /// 待处理(未满足)的审阅请求人数。 pub async fn pending_requests(&self, pull_id: Uuid) -> Result { Ok(pull_request_reviewer::Entity::find() .filter(pull_request_reviewer::Column::PullRequestId.eq(pull_id)) .filter(pull_request_reviewer::Column::Required.eq(true)) .filter( pull_request_reviewer::Column::State .eq(models::pull_request::pull_request_reviewer::ReviewerState::Requested), ) .count(self.db_read()) .await? as i32) } #[allow(clippy::too_many_arguments)] pub async fn merge_gate( &self, space_name: String, repo_name: String, ctx: &GitContext, pull: &pull_requests::Model, caller: i64, admin: bool, summary: &PullSummaryDto, ) -> Result { let policies = match_branch_policies(self.db_read(), ctx.model.id, &pull.base_branch).await?; let outdated = summary.commits_behind > 0; if policies.is_empty() { return Ok(MergeGateDto { allowed: true, reasons: Vec::new(), protected: false, required_approvals: 0, approvals: 0, change_requests: 0, outdated, bypassed: false, }); } let whitelists = branch_policy_whitelists(self.db_read(), &policies).await?; let required = policies .iter() .map(|policy| policy.required_approvals) .max() .unwrap_or(0); let dismiss_stale = policies.iter().any(|policy| policy.dismiss_stale_approvals); let merge_whitelist_on = policies.iter().any(|policy| policy.enable_merge_whitelist); let bypassed = admin || (merge_whitelist_on && whitelists.merge_allowed.contains(&caller)); let mut reasons: Vec = Vec::new(); let verdicts = if required > 0 || policies .iter() .any(|policy| policy.block_on_change_requested_reviews) { self.latest_verdicts(pull.id, &summary.head_sha, dismiss_stale) .await? } else { HashMap::new() }; let approvals = verdicts .values() .filter(|verdict| **verdict == ReviewVerdict::Approved) .count() as i32; let change_requests = verdicts .values() .filter(|verdict| **verdict == ReviewVerdict::ChangesRequested) .count() as i32; if !bypassed { if required > 0 && approvals < required { reasons.push(format!( "{required} approval(s) required, {approvals} granted on the current head commit" )); } if change_requests > 0 && policies .iter() .any(|policy| policy.block_on_rejected_reviews) { reasons.push(format!("change requested by {change_requests} reviewer(s)")); } if change_requests > 0 && policies .iter() .any(|policy| policy.block_on_change_requested_reviews) { reasons.push(format!( "unaddressed change request from {change_requests} reviewer(s)" )); } if outdated && policies .iter() .any(|policy| policy.block_on_outdated_branch) { reasons.push( "base branch has new commits; sync the branch before merging".to_string(), ); } if policies.iter().any(|policy| policy.require_signed_commits) { let commits = self .git .merge_commits( space_name.clone(), repo_name.clone(), &format!("refs/heads/{}", pull.base_branch), &summary.head_sha, ) .await?; if commits.len() > SIGNED_CHECK_CAP { return Err(PullError::Conflict(format!( "pull request carries more than {SIGNED_CHECK_CAP} commits; the commit signature policy cannot be verified" ))); } for commit in &commits { let verify = self .git .signature_verify_commit( space_name.clone(), repo_name.clone(), commit.sha.clone(), ) .await?; if !verify.signed || !verify.valid { reasons.push(format!("commit {} is not validly signed", commit.sha)); break; } } } let required_contexts: Vec = policies .iter() .flat_map(|policy| { policy .required_status_contexts .split(['\n', ',']) .map(|value| value.trim().to_string()) .collect::>() }) .filter(|value| !value.is_empty()) .collect(); if !required_contexts.is_empty() { let statuses = self .git .list_commit_statuses( space_name.clone(), repo_name.clone(), summary.head_sha.clone(), ) .await?; for context in &required_contexts { let state = statuses .iter() .find(|status| status.context == *context) .map(|status| status.state); match state { Some(git::service::commits::CommitStatusDtoState::Success) => {} Some(other) => reasons.push(format!( "required check `{context}` is {}", match other { git::service::commits::CommitStatusDtoState::Pending => "pending", git::service::commits::CommitStatusDtoState::Failure => "failed", git::service::commits::CommitStatusDtoState::Error => "errored", git::service::commits::CommitStatusDtoState::Success => "successful", } )), None => { reasons.push(format!("required check `{context}` has not reported yet")) } } } } let protected_raw: String = policies .iter() .map(|policy| policy.protected_file_patterns.as_str()) .collect::>() .join(","); let unprotected_raw: String = policies .iter() .map(|policy| policy.unprotected_file_patterns.as_str()) .collect::>() .join(","); if !protected_raw.trim().is_empty() || !unprotected_raw.trim().is_empty() { let from = summary .merge_base .clone() .unwrap_or_else(|| summary.base_sha.clone()); let changed = self .git .diff_files_changed_between( space_name.clone(), repo_name.clone(), from, summary.head_sha.clone(), ) .await?; let protected = build_glob_set(&protected_raw)?; let unprotected = build_glob_set(&unprotected_raw)?; let blocked: Vec = changed .into_iter() .filter(|path| { let hit = protected.as_ref().is_some_and(|set| set.is_match(path)); let exempt = unprotected.as_ref().is_some_and(|set| set.is_match(path)); hit && !exempt }) .collect(); if !blocked.is_empty() { reasons.push(format!( "protected file(s) changed without whitelist permission: {}", blocked.join(", ") )); } } } Ok(MergeGateDto { allowed: reasons.is_empty(), reasons, protected: true, required_approvals: required, approvals, change_requests, outdated, bypassed, }) } }