use git::facade::{ExecuteMergeCmd, GitContext, MergeResultDto}; use models::pull_request::pull_requests; use models::pull_request::pull_requests::MergeMethod; use sea_orm::ActiveValue::Set; use sea_orm::{ActiveModelTrait, IntoActiveModel, TransactionTrait}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::errors::PullError; use crate::services::changes::PullSummaryDto; use crate::services::service::PullService; use crate::share::authz; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct MergePullCmd { /// merge | squash | rebase #[serde(default)] pub method: Option, #[serde(default)] pub title: Option, #[serde(default)] pub message: Option, /// 允许在无冲突时直接快进(method=merge 时生效)。 #[serde(default)] pub fast_forward_only: Option, #[serde(default)] pub expect_head_sha: Option, #[serde(default)] pub delete_branch_after_merge: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct MergePullDto { pub pull: super::pulls::PullDto, pub result: MergeResultDto, pub summary: PullSummaryDto, } impl PullService { /// `handle` carries the resolved repository, pull row, caller and admin bit /// so the signature does not repeat four positionally-similar arguments. #[tracing::instrument( name = "pulls.merge", skip_all, fields(space = %space_name, repo = %repo_name, number = handle.pull.autoincrement_id) )] pub async fn merge_pull( &self, space_name: String, repo_name: String, handle: &crate::share::lookup::PullHandle, cmd: MergePullCmd, ) -> Result<(pull_requests::Model, MergeResultDto, PullSummaryDto), PullError> { let crate::share::lookup::PullHandle { ctx, pull, admin, .. } = handle; // Absorbed from the handler: merging always needs an authenticated // caller, and the resolved handle is the single place it is known. let caller = handle.caller.ok_or(PullError::Unauthorized)?; let admin = *admin; authz::require_open(pull)?; if pull.is_draft { return Err(PullError::Conflict( "pull request is still a draft".to_string(), )); } if let Some(expected) = cmd.expect_head_sha.as_deref() && expected != pull.head_sha { return Err(PullError::Conflict(format!( "head moved: expected {expected}, found {}", pull.head_sha ))); } let (current, summary) = self .refresh_pull(space_name.clone(), repo_name.clone(), pull, Some(caller)) .await?; if summary.commits_ahead == 0 { return Err(PullError::Conflict( "nothing to merge; head is already contained in base".to_string(), )); } if !summary.mergeable { return Err(PullError::Conflict(format!( "merge conflict in {} file(s): {}", summary.conflicts.len(), summary.conflicts.join(", ") ))); } let gate = self .merge_gate(ctx, ¤t, caller, admin, &summary) .await?; if !gate.allowed { return Err(PullError::Conflict(gate.reasons.join("; "))); } let method = cmd.method.unwrap_or(current.merge_method); match method { MergeMethod::Merge => {} MergeMethod::Squash => { if !ctx.model.allow_squash_merge { return Err(PullError::Forbidden); } } MergeMethod::Rebase => { if !ctx.model.allow_rebase && !ctx.model.allow_rebase_explicit { return Err(PullError::Forbidden); } } } let subject = cmd.title.clone().unwrap_or_else(|| current.title.clone()); let message = cmd.message.clone().or_else(|| { cmd.title .as_ref() .map(|_| format!("{} (#{})", subject, current.autoincrement_id)) }); let summary_label = format!("{} (#{})", subject, current.autoincrement_id); let result = self .git .merge_execute( space_name.clone(), repo_name.clone(), ExecuteMergeCmd { base_branch: current.base_branch.clone(), head_revision: summary.head_sha.clone(), method, message, caller, allow_ff_only: cmd.fast_forward_only.unwrap_or(true), summary: summary_label, }, ) .await?; let squash_sha = if result.method == "squash" { Some(result.new_sha.clone()) } else { None }; let merged = self .apply_merge_outcome(¤t, caller, result.new_sha.clone(), squash_sha, method) .await?; let default_branch = self .git .baby(&ctx) .get_default_branch() .await .unwrap_or_default(); if cmd.delete_branch_after_merge.unwrap_or(false) && merged.head_branch != default_branch && let Err(error) = self .git .delete_branch(&ctx, merged.head_branch.clone(), false) .await { tracing::warn!( error = %error, branch = %merged.head_branch, "merged pull request head branch cleanup failed" ); } Ok((merged, result, summary)) } /// 把 base 分支的最新提交并入 head 分支(更新 PR 分支)。 pub async fn update_pull_branch( &self, space_name: String, repo_name: String, ctx: &GitContext, pull: &pull_requests::Model, caller: i64, admin: bool, ) -> Result<(pull_requests::Model, MergeResultDto), PullError> { authz::require_open(pull)?; let _ = ctx; if pull.is_locked && !admin { return Err(PullError::Forbidden); } let (current, summary) = self .refresh_pull(space_name.clone(), repo_name.clone(), pull, Some(caller)) .await?; if summary.commits_behind == 0 { return Err(PullError::Conflict( "branch is already up to date".to_string(), )); } let result = self .git .merge_execute( space_name.clone(), repo_name.clone(), ExecuteMergeCmd { base_branch: current.head_branch.clone(), head_revision: format!("refs/heads/{}", current.base_branch), method: MergeMethod::Merge, message: Some(format!( "Merge branch '{}' into '{}'", current.base_branch, current.head_branch )), caller, allow_ff_only: false, summary: format!("update #{}", current.autoincrement_id), }, ) .await?; let head_sha = result.new_sha.clone(); let mut model = current.into_active_model(); model.head_sha = Set(head_sha.clone()); model.updated_at = Set(Some(time::OffsetDateTime::now_utc())); let this = self.clone(); let updated = self .db_write() .transaction::<_, _, PullError>(move |tx| { Box::pin(async move { let updated = model.update(tx).await?; this.record_event_with( tx, &updated, Some(caller), crate::services::events::EVENT_HEAD_UPDATED, Some(head_sha), Some("base branch merged into head".to_string()), ) .await?; Ok(updated) }) }) .await?; Ok((updated, result)) } }