use std::collections::HashMap; use git::facade::GitContext; 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) } pub async fn merge_gate( &self, ctx: &GitContext, pull: &pull_requests::Model, caller: i64, admin: bool, summary: &PullSummaryDto, ) -> Result { let policy = self .git .merge_requirements(ctx, &pull.base_branch, caller) .await?; let outdated = summary.commits_behind > 0; let Some(policy) = policy else { return Ok(MergeGateDto { allowed: true, reasons: Vec::new(), protected: false, required_approvals: 0, approvals: 0, change_requests: 0, outdated, bypassed: false, }); }; let required = policy.required_approvals; let dismiss_stale = policy.dismiss_stale_approvals; let bypassed = admin || policy.whitelist_bypass; let mut reasons: Vec = Vec::new(); let verdicts = if required > 0 || 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 && policy.block_on_rejected_reviews { reasons.push(format!("change requested by {change_requests} reviewer(s)")); } if change_requests > 0 && policy.block_on_change_requested_reviews { reasons.push(format!( "unaddressed change request from {change_requests} reviewer(s)" )); } if outdated && policy.block_on_outdated_branch { reasons.push( "base branch has new commits; sync the branch before merging".to_string(), ); } if policy.require_signed_commits { let commits = self .git .merge_commits( ctx, &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(ctx, commit.sha.clone()) .await?; if !verify.signed || !verify.valid { reasons.push(format!("commit {} is not validly signed", commit.sha)); break; } } } let required_contexts = &policy.required_status_contexts; if !required_contexts.is_empty() { let statuses = self .git .list_commit_statuses(ctx, 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::facade::CommitStatusDtoState::Success) => {} Some(other) => reasons.push(format!( "required check `{context}` is {}", match other { git::facade::CommitStatusDtoState::Pending => "pending", git::facade::CommitStatusDtoState::Failure => "failed", git::facade::CommitStatusDtoState::Error => "errored", git::facade::CommitStatusDtoState::Success => "successful", } )), None => { reasons.push(format!("required check `{context}` has not reported yet")) } } } } let protected_raw = &policy.protected_file_patterns; let unprotected_raw = &policy.unprotected_file_patterns; 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(ctx, 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, }) } }