f0a1552
ZhenYi
feat(chat): add chat module with room management, message handling, and session revocation
f0a1552
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<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<Uuid, 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<Uuid, 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)
}
#[allow(clippy::too_many_arguments)]
pub async fn merge_gate(
&self,
space_name: String,
repo_name: String,
ctx: &GitContext,
pull: &pull_requests::Model,
caller: Uuid,
admin: bool,
summary: &PullSummaryDto,
) -> Result<MergeGateDto, PullError> {
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
.iter()
.any(|entry| *entry == caller));
let mut reasons: Vec<String> = 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 protected_raw: String = policies
.iter()
.map(|policy| policy.protected_file_patterns.as_str())
.collect::<Vec<_>>()
.join(",");
let unprotected_raw: String = policies
.iter()
.map(|policy| policy.unprotected_file_patterns.as_str())
.collect::<Vec<_>>()
.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<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,
})
}
}