Skip to content
Clone
b453d5d
232 lines 7.9 KB Rust
use git::context::facade::GitContext;
use git::service::merge::{ExecuteMergeCmd, MergeResultDto};
use models::pull_request::pull_requests;
use models::pull_request::pull_requests::MergeMethod;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, IntoActiveModel};
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::require_open;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct MergePullCmd {
/// merge | squash | rebase
#[serde(default)]
pub method: Option<MergeMethod>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub message: Option<String>,
/// 允许在无冲突时直接快进(method=merge 时生效)。
#[serde(default)]
pub fast_forward_only: Option<bool>,
#[serde(default)]
pub expect_head_sha: Option<String>,
#[serde(default)]
pub delete_branch_after_merge: Option<bool>,
}
#[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;
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(
space_name.clone(),
repo_name.clone(),
ctx,
&current,
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(&current, caller, result.new_sha.clone(), squash_sha, method)
.await?;
if cmd.delete_branch_after_merge.unwrap_or(false)
&& merged.head_branch != ctx.model.default_branch
&& let Err(error) = self
.git
.delete_branch(
space_name.clone(),
repo_name.clone(),
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> {
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 updated = model.update(self.db_write()).await?;
self.record_event(
&updated,
Some(caller),
crate::services::events::EVENT_HEAD_UPDATED,
Some(head_sha),
Some("base branch merged into head".to_string()),
)
.await?;
Ok((updated, result))
}
}