Skip to content
Clone
b71a6f6
271 lines 10.1 KB Rust
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<String>,
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<Option<GlobSet>, 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<HashMap<i64, ReviewVerdict>, 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<i64, ReviewVerdict> = 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<i32, PullError> {
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<MergeGateDto, PullError> {
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<String> = 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<String> = 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,
})
}
}