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, /// Strategies supported given the current state of the PR. pub supported_strategies: Vec, } #[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, } #[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, } 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 { 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 { 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 { 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 { 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, 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) } }