455 lines
15 KiB
Rust
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)
|
|
}
|
|
}
|