gitdataai/libs/service/pull_request/merge.rs
2026-04-14 19:02:01 +08:00

455 lines
15 KiB
Rust

use crate::AppService;
use crate::error::AppError;
use crate::project::activity::ActivityLogParams;
use chrono::Utc;
use models::pull_request::{PrStatus, pull_request};
use models::repos::repo;
use models::users::user;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum MergeStrategy {
MergeCommit,
Squash,
Rebase,
}
impl Default for MergeStrategy {
fn default() -> Self {
MergeStrategy::MergeCommit
}
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MergeAnalysisResponse {
pub can_fast_forward: bool,
pub is_up_to_date: bool,
pub is_normal: bool,
pub analysis_flags: Vec<String>,
/// Strategies supported given the current state of the PR.
pub supported_strategies: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct MergeRequest {
#[serde(default)]
pub fast_forward: bool,
#[serde(default)]
pub strategy: MergeStrategy,
#[serde(default = "default_merge_message")]
pub message: String,
}
fn default_merge_message() -> String {
"Merge pull request".to_string()
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MergeResponse {
pub repo: Uuid,
pub number: i64,
pub status: String,
pub merged_by: Uuid,
pub merged_at: chrono::DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MergeConflictFile {
pub path: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MergeConflictResponse {
pub has_conflicts: bool,
pub conflicted_files: Vec<MergeConflictFile>,
}
fn resolve_ref_name(name: &str) -> String {
if name.starts_with("refs/") {
name.to_string()
} else if name.contains('/') {
format!("refs/heads/{}", name)
} else {
format!("refs/heads/{}", name)
}
}
impl AppService {
/// Analyze merge readiness of a pull request.
pub async fn merge_analysis(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
ctx: &Session,
) -> Result<MergeAnalysisResponse, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let pr = pull_request::Entity::find()
.filter(pull_request::Column::Repo.eq(repo.id))
.filter(pull_request::Column::Number.eq(pr_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pull request not found".to_string()))?;
let domain = git::GitDomain::from_model(repo)?;
let head_ref_name = resolve_ref_name(&pr.head);
let head_oid = domain
.ref_target(&head_ref_name)?
.ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?;
let (analysis, _pref) = domain.merge_analysis_for_ref(&pr.base, &head_oid)?;
let mut flags = Vec::new();
if analysis.is_fast_forward {
flags.push("fast_forward".to_string());
}
if analysis.is_up_to_date {
flags.push("up_to_date".to_string());
}
if analysis.is_normal {
flags.push("normal".to_string());
}
// Determine supported strategies.
// All three strategies are always available for open PRs.
let supported_strategies = if analysis.is_up_to_date {
// Already merged — no strategies available
vec![]
} else {
vec![
"mergecommit".to_string(),
"squash".to_string(),
"rebase".to_string(),
]
};
Ok(MergeAnalysisResponse {
can_fast_forward: analysis.is_fast_forward,
is_up_to_date: analysis.is_up_to_date,
is_normal: analysis.is_normal,
analysis_flags: flags,
supported_strategies,
})
}
pub async fn merge_conflict_check(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
ctx: &Session,
) -> Result<MergeConflictResponse, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let pr = pull_request::Entity::find()
.filter(pull_request::Column::Repo.eq(repo.id))
.filter(pull_request::Column::Number.eq(pr_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pull request not found".to_string()))?;
let domain = git::GitDomain::from_model(repo)?;
let head_ref_name = resolve_ref_name(&pr.head);
let head_oid = domain
.ref_target(&head_ref_name)?
.ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?;
let (analysis, _pref) = domain.merge_analysis_for_ref(&pr.base, &head_oid)?;
let has_conflicts =
!analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted();
if has_conflicts {
let conflicted_files = self.get_conflicted_files(&domain)?;
Ok(MergeConflictResponse {
has_conflicts: true,
conflicted_files,
})
} else {
Ok(MergeConflictResponse {
has_conflicts: false,
conflicted_files: vec![],
})
}
}
/// ONLY admin/owner of the target repo can merge.
pub async fn merge_execute(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
request: MergeRequest,
ctx: &Session,
) -> Result<MergeResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo: repo::Model = self
.utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx)
.await?;
let pr = pull_request::Entity::find()
.filter(pull_request::Column::Repo.eq(repo.id))
.filter(pull_request::Column::Number.eq(pr_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pull request not found".to_string()))?;
if pr.status == PrStatus::Merged.to_string() {
return Err(AppError::BadRequest(
"Pull request is already merged".to_string(),
));
}
if pr.status == PrStatus::Closed.to_string() {
return Err(AppError::BadRequest(
"Cannot merge a closed pull request".to_string(),
));
}
let protection = self.branch_protection_find(repo.id, &pr.base).await?;
if let Some(ref rule) = protection {
if rule.forbid_merge {
return Err(AppError::Forbidden(format!(
"Branch '{}' is protected: merges are forbidden",
pr.base
)));
}
if rule.required_approvals > 0 {
let approval_check = self
.branch_protection_check_approvals(
namespace.clone(),
repo_name.clone(),
pr_number,
ctx,
)
.await?;
if !approval_check.enough_approvals {
return Err(AppError::Forbidden(format!(
"Branch '{}' requires {} approval(s), but only {} found",
pr.base, rule.required_approvals, approval_check.approvals
)));
}
}
if rule.require_linear_history && request.strategy == MergeStrategy::MergeCommit {
return Err(AppError::Forbidden(format!(
"Branch '{}' requires linear history: merge commits are not allowed, use squash or rebase instead",
pr.base
)));
}
}
let domain = git::GitDomain::from_model(repo.clone())?;
let head_ref_name = resolve_ref_name(&pr.head);
let head_oid = domain
.ref_target(&head_ref_name)?
.ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?;
let base_oid = domain
.ref_target(&resolve_ref_name(&pr.base))?
.ok_or_else(|| AppError::BadRequest("Base ref has no OID".to_string()))?;
let (analysis, _pref) = domain.merge_analysis_for_ref(&pr.base, &head_oid)?;
if !analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted() {
return Err(AppError::BadRequest(
"Pull request has merge conflicts".to_string(),
));
}
// Build merge commit message
let merge_msg = if request.message == default_merge_message() {
format!("{} (#{})\n\n{}", pr.title, pr_number, pr.title)
} else {
request.message
};
// Get author signature for merge commit
let sig = domain.commit_default_signature()?;
let committer = sig.clone();
if analysis.is_fast_forward && request.fast_forward {
// Fast-forward: move base ref forward to head
let base_ref_name = resolve_ref_name(&pr.base);
domain.ref_update(&base_ref_name, head_oid.clone(), None, None)?;
} else {
match request.strategy {
MergeStrategy::MergeCommit => {
domain.merge_commits(&base_oid, &head_oid, None)?;
// Write the merge commit from the merge index
let merge_oid = domain.commit_create_from_index(
None,
&sig,
&committer,
&merge_msg,
&[base_oid.clone(), head_oid.clone()],
)?;
let base_ref_name = resolve_ref_name(&pr.base);
domain.ref_update(&base_ref_name, merge_oid, None, None)?;
let _ = domain.merge_abort();
}
MergeStrategy::Squash => {
// Squash all commits from source branch into one on top of base
let squash_oid = domain.squash_commits(&base_oid, &pr.head)?;
let base_ref_name = resolve_ref_name(&pr.base);
domain.ref_update(&base_ref_name, squash_oid, None, None)?;
}
MergeStrategy::Rebase => {
// Rebase source commits onto base
let rebase_oid = domain.rebase_commits(&base_oid, &head_oid)?;
let base_ref_name = resolve_ref_name(&pr.base);
domain.ref_update(&base_ref_name, rebase_oid, None, None)?;
}
}
}
let now = Utc::now();
let mut active: pull_request::ActiveModel = pr.clone().into();
active.status = Set(PrStatus::Merged.to_string());
active.merged_by = Set(Some(user_uid));
active.merged_at = Set(Some(now));
active.updated_at = Set(now);
let merged_model = active.update(&self.db).await?;
super::invalidate_pr_cache(&self.cache, repo.id, pr_number).await;
let actor_username = user::Entity::find_by_id(user_uid)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username)
.unwrap_or_default();
let _ = self
.project_log_activity(
repo.project,
Some(repo.id),
user_uid,
super::super::project::activity::ActivityLogParams {
event_type: "pr_merge".to_string(),
title: format!("{} merged pull request #{}", actor_username, pr_number),
repo_id: Some(repo.id),
content: Some(merged_model.title),
event_id: None,
event_sub_id: Some(pr_number),
metadata: Some(serde_json::json!({
"base": pr.clone().base,
"head": pr.head.clone(),
})),
is_private: false,
},
)
.await;
Ok(MergeResponse {
repo: repo.id,
number: pr_number,
status: PrStatus::Merged.to_string(),
merged_by: user_uid,
merged_at: now,
})
}
pub async fn merge_abort(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
ctx: &Session,
) -> Result<(), AppError> {
let repo: repo::Model = self
.utils_check_repo_admin(namespace, repo_name, ctx)
.await?;
let _pr = pull_request::Entity::find()
.filter(pull_request::Column::Repo.eq(repo.id))
.filter(pull_request::Column::Number.eq(pr_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pull request not found".to_string()))?;
let domain = git::GitDomain::from_model(repo.clone())?;
domain.merge_abort()?;
let user_uid = ctx.user().unwrap_or(Uuid::nil());
let _ = self
.project_log_activity(
repo.project,
Some(repo.id),
user_uid,
ActivityLogParams {
event_type: "pr_merge_abort".to_string(),
title: format!("{} aborted merge for PR #{}", user_uid, pr_number),
repo_id: Some(repo.id),
content: None,
event_id: None,
event_sub_id: Some(pr_number),
metadata: Some(serde_json::json!({
"pr_number": pr_number,
})),
is_private: false,
},
)
.await;
Ok(())
}
pub async fn merge_is_in_progress(
&self,
namespace: String,
repo_name: String,
pr_number: i64,
ctx: &Session,
) -> Result<bool, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let _pr = pull_request::Entity::find()
.filter(pull_request::Column::Repo.eq(repo.id))
.filter(pull_request::Column::Number.eq(pr_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pull request not found".to_string()))?;
let domain = git::GitDomain::from_model(repo)?;
Ok(domain.merge_is_in_progress())
}
fn get_conflicted_files(
&self,
domain: &git::GitDomain,
) -> Result<Vec<MergeConflictFile>, AppError> {
let index = domain
.repo()
.index()
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let files = match index.conflicts() {
Ok(conflicts) => conflicts
.filter_map(|result| result.ok())
.filter_map(|conflict| {
conflict.our.as_ref().map(|entry| MergeConflictFile {
path: String::from_utf8_lossy(&entry.path).to_string(),
status: "both_modified".to_string(),
})
})
.collect(),
Err(_) => vec![],
};
Ok(files)
}
}