cascade_cli/bitbucket/
pull_request.rs

1use crate::bitbucket::client::BitbucketClient;
2use crate::errors::{CascadeError, Result};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6use tracing::{debug, info};
7
8/// Pull request manager for Bitbucket operations
9pub struct PullRequestManager {
10    client: BitbucketClient,
11}
12
13impl PullRequestManager {
14    /// Create a new pull request manager
15    pub fn new(client: BitbucketClient) -> Self {
16        Self { client }
17    }
18
19    /// Create a new pull request
20    pub async fn create_pull_request(
21        &self,
22        request: CreatePullRequestRequest,
23    ) -> Result<PullRequest> {
24        // Creating pull request
25
26        // Debug the request being sent
27        debug!(
28            "PR Request - Title: '{}', Description: {:?}, Draft: {}",
29            request.title, request.description, request.draft
30        );
31
32        let pr: PullRequest = self.client.post("pull-requests", &request).await?;
33
34        // Pull request created successfully
35        Ok(pr)
36    }
37
38    /// Get a pull request by ID
39    pub async fn get_pull_request(&self, pr_id: u64) -> Result<PullRequest> {
40        self.client.get(&format!("pull-requests/{pr_id}")).await
41    }
42
43    /// Update a pull request (title, description, etc)
44    pub async fn update_pull_request(
45        &self,
46        pr_id: u64,
47        title: Option<String>,
48        description: Option<String>,
49        version: u64,
50    ) -> Result<PullRequest> {
51        #[derive(Debug, Serialize)]
52        struct UpdatePullRequestRequest {
53            #[serde(skip_serializing_if = "Option::is_none")]
54            title: Option<String>,
55            #[serde(skip_serializing_if = "Option::is_none")]
56            description: Option<String>,
57            version: u64,
58        }
59
60        let request = UpdatePullRequestRequest {
61            title,
62            description,
63            version,
64        };
65
66        self.client
67            .put(&format!("pull-requests/{pr_id}"), &request)
68            .await
69    }
70
71    /// List pull requests with optional filters
72    pub async fn list_pull_requests(
73        &self,
74        state: Option<PullRequestState>,
75    ) -> Result<PullRequestPage> {
76        let mut path = "pull-requests".to_string();
77
78        if let Some(state) = state {
79            path.push_str(&format!("?state={}", state.as_str()));
80        }
81
82        self.client.get(&path).await
83    }
84
85    /// Update a pull request's source branch by closing the old PR and creating a new one
86    /// This is needed because Bitbucket doesn't allow changing PR source branches
87    pub async fn update_source_branch(
88        &self,
89        old_pr_id: u64,
90        new_request: CreatePullRequestRequest,
91        close_reason: Option<String>,
92    ) -> Result<PullRequest> {
93        info!(
94            "Updating PR #{} source branch: {} -> {}",
95            old_pr_id, old_pr_id, new_request.from_ref.display_id
96        );
97
98        // First, get the old PR to preserve information
99        let old_pr = self.get_pull_request(old_pr_id).await?;
100
101        // Close/decline the old PR with a descriptive message
102        let close_message = close_reason.unwrap_or_else(|| {
103            format!(
104                "Superseded by updated branch: {}",
105                new_request.from_ref.display_id
106            )
107        });
108
109        self.decline_pull_request(old_pr_id, &close_message).await?;
110
111        // Create new PR with the same title/description but new branch
112        let new_request = CreatePullRequestRequest {
113            title: format!("{} (Updated)", old_pr.title),
114            description: old_pr.description.clone(),
115            from_ref: new_request.from_ref,
116            to_ref: new_request.to_ref,
117            draft: new_request.draft,
118        };
119
120        let new_pr = self.create_pull_request(new_request).await?;
121
122        info!("Closed PR #{} and created new PR #{}", old_pr_id, new_pr.id);
123        Ok(new_pr)
124    }
125
126    /// Decline a pull request with a reason
127    pub async fn decline_pull_request(&self, pr_id: u64, reason: &str) -> Result<()> {
128        info!("Declining pull request #{}: {}", pr_id, reason);
129
130        #[derive(Serialize)]
131        struct DeclineRequest {
132            version: u64,
133            #[serde(rename = "participantStatus")]
134            participant_status: String,
135        }
136
137        // First get the current PR to get its version
138        let pr = self.get_pull_request(pr_id).await?;
139
140        let decline_body = DeclineRequest {
141            version: pr.version,
142            participant_status: "DECLINED".to_string(),
143        };
144
145        let path = format!("pull-requests/{pr_id}/decline");
146
147        // Use the client to make the decline request
148        let _: serde_json::Value = self.client.post(&path, &decline_body).await?;
149
150        info!("Successfully declined pull request #{}", pr_id);
151        Ok(())
152    }
153
154    /// Add a comment to a pull request explaining the branch update
155    pub async fn add_comment(&self, pr_id: u64, comment: &str) -> Result<()> {
156        info!("Adding comment to PR #{}", pr_id);
157
158        #[derive(Serialize)]
159        struct CommentRequest {
160            text: String,
161        }
162
163        let comment_body = CommentRequest {
164            text: comment.to_string(),
165        };
166
167        let path = format!("pull-requests/{pr_id}/comments");
168        let _: serde_json::Value = self.client.post(&path, &comment_body).await?;
169
170        info!("Added comment to PR #{}", pr_id);
171        Ok(())
172    }
173
174    /// Get comprehensive status information for a pull request
175    pub async fn get_pull_request_status(&self, pr_id: u64) -> Result<PullRequestStatus> {
176        // Get the pull request
177        let pr = self.get_pull_request(pr_id).await?;
178
179        // Get detailed mergability information (includes all server-side checks)
180        let mergeable_details = self.check_mergeable_detailed(pr_id).await.ok();
181        let mergeable = mergeable_details.as_ref().map(|d| d.can_merge);
182
183        // Get participants and calculate review status
184        let participants = self.get_pull_request_participants(pr_id).await?;
185        let review_status = self.calculate_review_status(&participants)?;
186
187        // Get build status (fallback gracefully if not available)
188        let build_status = self.get_build_status(pr_id).await.ok();
189
190        // Get conflicts (fallback gracefully if not available)
191        let conflicts = self.get_conflicts(pr_id).await.ok();
192
193        Ok(PullRequestStatus {
194            pr,
195            mergeable,
196            mergeable_details,
197            participants,
198            build_status,
199            review_status,
200            conflicts,
201        })
202    }
203
204    /// Get all participants (including reviewers) for a PR
205    pub async fn get_pull_request_participants(&self, pr_id: u64) -> Result<Vec<Participant>> {
206        let path = format!("pull-requests/{pr_id}/participants");
207        let response: ParticipantsResponse = self.client.get(&path).await?;
208        Ok(response.values)
209    }
210
211    /// Check if PR is mergeable and get detailed blocking reasons
212    pub async fn check_mergeable_detailed(&self, pr_id: u64) -> Result<MergeabilityDetails> {
213        let path = format!("pull-requests/{pr_id}/merge");
214
215        match self.client.get::<serde_json::Value>(&path).await {
216            Ok(response) => {
217                let can_merge = response
218                    .get("canMerge")
219                    .and_then(|v| v.as_bool())
220                    .unwrap_or(false);
221
222                let conflicted = response
223                    .get("conflicted")
224                    .and_then(|v| v.as_bool())
225                    .unwrap_or(false);
226
227                // Extract detailed veto reasons if present
228                let mut blocking_reasons = Vec::new();
229
230                if let Some(vetoes) = response.get("vetoes").and_then(|v| v.as_array()) {
231                    for veto in vetoes {
232                        if let Some(summary) = veto.get("summaryMessage").and_then(|s| s.as_str()) {
233                            blocking_reasons.push(summary.to_string());
234                        } else if let Some(detailed) =
235                            veto.get("detailedMessage").and_then(|s| s.as_str())
236                        {
237                            blocking_reasons.push(detailed.to_string());
238                        }
239                    }
240                }
241
242                // Add conflict information
243                if conflicted {
244                    blocking_reasons.push("Pull request has merge conflicts".to_string());
245                }
246
247                Ok(MergeabilityDetails {
248                    can_merge,
249                    conflicted,
250                    blocking_reasons,
251                    server_enforced: true, // This comes from Bitbucket's authoritative check
252                })
253            }
254            Err(_) => {
255                // Fallback: assume mergeable but note we couldn't check
256                Ok(MergeabilityDetails {
257                    can_merge: true,
258                    conflicted: false,
259                    blocking_reasons: vec!["Could not verify merge conditions".to_string()],
260                    server_enforced: false,
261                })
262            }
263        }
264    }
265
266    /// Check if PR is mergeable (legacy method - kept for backward compatibility)
267    pub async fn check_mergeable(&self, pr_id: u64) -> Result<bool> {
268        let details = self.check_mergeable_detailed(pr_id).await?;
269        Ok(details.can_merge)
270    }
271
272    /// Get build status for a PR
273    pub async fn get_build_status(&self, pr_id: u64) -> Result<BuildStatus> {
274        let pr = self.get_pull_request(pr_id).await?;
275        let commit_hash = &pr.from_ref.latest_commit;
276
277        // Get build status for the latest commit
278        let path = format!("commits/{commit_hash}/builds");
279
280        match self.client.get::<BuildStatusResponse>(&path).await {
281            Ok(response) => {
282                if let Some(build) = response.values.first() {
283                    Ok(BuildStatus {
284                        state: build.state.clone(),
285                        url: build.url.clone(),
286                        description: build.description.clone(),
287                        context: build.name.clone(),
288                    })
289                } else {
290                    Ok(BuildStatus {
291                        state: BuildState::Unknown,
292                        url: None,
293                        description: Some("No builds found".to_string()),
294                        context: None,
295                    })
296                }
297            }
298            Err(_) => Ok(BuildStatus {
299                state: BuildState::Unknown,
300                url: None,
301                description: Some("Build status unavailable".to_string()),
302                context: None,
303            }),
304        }
305    }
306
307    /// Get conflict information for a PR
308    pub async fn get_conflicts(&self, pr_id: u64) -> Result<Vec<String>> {
309        let path = format!("pull-requests/{pr_id}/diff");
310
311        // This is a simplified approach - in practice you'd parse the diff
312        // to find actual conflict markers or use specialized endpoints
313        match self.client.get::<DiffResponse>(&path).await {
314            Ok(_diff) => {
315                // TODO: Parse diff for conflict markers
316                // For now, return empty conflicts
317                Ok(Vec::new())
318            }
319            Err(_) => Ok(Vec::new()),
320        }
321    }
322
323    /// Calculate review status based on participants
324    fn calculate_review_status(&self, participants: &[Participant]) -> Result<ReviewStatus> {
325        let mut current_approvals = 0;
326        let mut needs_work_count = 0;
327        let mut missing_reviewers = Vec::new();
328
329        for participant in participants {
330            match participant.status {
331                ParticipantStatus::Approved => current_approvals += 1,
332                ParticipantStatus::NeedsWork => needs_work_count += 1,
333                ParticipantStatus::Unapproved => {
334                    if matches!(participant.role, ParticipantRole::Reviewer) {
335                        missing_reviewers.push(
336                            participant
337                                .user
338                                .display_name
339                                .clone()
340                                .unwrap_or_else(|| participant.user.name.clone()),
341                        );
342                    }
343                }
344            }
345        }
346
347        // Minimum 1 approval required, no "needs work" votes
348        let required_approvals = 1; // This could be configurable
349        let can_merge = current_approvals >= required_approvals && needs_work_count == 0;
350
351        Ok(ReviewStatus {
352            required_approvals,
353            current_approvals,
354            needs_work_count,
355            can_merge,
356            missing_reviewers,
357        })
358    }
359
360    /// Merge a pull request using Bitbucket Server API
361    pub async fn merge_pull_request(
362        &self,
363        pr_id: u64,
364        merge_strategy: MergeStrategy,
365    ) -> Result<PullRequest> {
366        let pr = self.get_pull_request(pr_id).await?;
367
368        let merge_request = MergePullRequestRequest {
369            version: pr.version,
370            message: merge_strategy.get_commit_message(&pr),
371            strategy: merge_strategy,
372        };
373
374        self.client
375            .post(&format!("pull-requests/{pr_id}/merge"), &merge_request)
376            .await
377    }
378
379    /// Auto-merge a pull request if conditions are met
380    pub async fn auto_merge_if_ready(
381        &self,
382        pr_id: u64,
383        conditions: &AutoMergeConditions,
384    ) -> Result<AutoMergeResult> {
385        let status = self.get_pull_request_status(pr_id).await?;
386
387        if !status.can_auto_merge(conditions) {
388            return Ok(AutoMergeResult::NotReady {
389                blocking_reasons: status.get_blocking_reasons(),
390            });
391        }
392
393        // Wait for any pending builds if required
394        if conditions.wait_for_builds {
395            self.wait_for_builds(pr_id, conditions.build_timeout)
396                .await?;
397        }
398
399        // Perform the merge
400        let merged_pr = self
401            .merge_pull_request(pr_id, conditions.merge_strategy.clone())
402            .await?;
403
404        Ok(AutoMergeResult::Merged {
405            pr: Box::new(merged_pr),
406            merge_strategy: conditions.merge_strategy.clone(),
407        })
408    }
409
410    /// Wait for builds to complete with timeout
411    async fn wait_for_builds(&self, pr_id: u64, timeout: Duration) -> Result<()> {
412        use tokio::time::{sleep, timeout as tokio_timeout};
413
414        tokio_timeout(timeout, async {
415            loop {
416                let build_status = self.get_build_status(pr_id).await?;
417
418                match build_status.state {
419                    BuildState::Successful => return Ok(()),
420                    BuildState::Failed | BuildState::Cancelled => {
421                        return Err(CascadeError::bitbucket(format!(
422                            "Build failed: {}",
423                            build_status.description.unwrap_or_default()
424                        )));
425                    }
426                    BuildState::InProgress => {
427                        sleep(Duration::from_secs(30)).await; // Poll every 30s
428                        continue;
429                    }
430                    BuildState::Unknown => {
431                        return Err(CascadeError::bitbucket("Build status unknown".to_string()));
432                    }
433                }
434            }
435        })
436        .await
437        .map_err(|_| CascadeError::bitbucket("Build timeout exceeded".to_string()))?
438    }
439}
440
441/// Request to create a new pull request
442#[derive(Debug, Serialize, Deserialize, Clone)]
443pub struct CreatePullRequestRequest {
444    pub title: String,
445    pub description: Option<String>,
446    #[serde(rename = "fromRef")]
447    pub from_ref: PullRequestRef,
448    #[serde(rename = "toRef")]
449    pub to_ref: PullRequestRef,
450    #[serde(rename = "isDraft")]
451    pub draft: bool,
452}
453
454/// Pull request data structure
455#[derive(Debug, Clone, Deserialize, Serialize)]
456pub struct PullRequest {
457    pub id: u64,
458    pub version: u64,
459    pub title: String,
460    pub description: Option<String>,
461    pub state: PullRequestState,
462    pub open: bool,
463    pub closed: bool,
464    #[serde(rename = "createdDate")]
465    pub created_date: u64,
466    #[serde(rename = "updatedDate")]
467    pub updated_date: u64,
468    #[serde(rename = "fromRef")]
469    pub from_ref: PullRequestRef,
470    #[serde(rename = "toRef")]
471    pub to_ref: PullRequestRef,
472    pub locked: bool,
473    pub author: Participant,
474    pub links: PullRequestLinks,
475}
476
477/// Pull request reference (branch information)
478#[derive(Debug, Clone, Deserialize, Serialize)]
479pub struct PullRequestRef {
480    pub id: String,
481    #[serde(rename = "displayId")]
482    pub display_id: String,
483    #[serde(rename = "latestCommit")]
484    pub latest_commit: String,
485    pub repository: Repository,
486}
487
488/// Repository information in pull request context
489#[derive(Debug, Clone, Deserialize, Serialize)]
490pub struct Repository {
491    pub id: u64,
492    pub name: String,
493    pub slug: String,
494    #[serde(rename = "scmId")]
495    pub scm_id: String,
496    pub state: String,
497    #[serde(rename = "statusMessage")]
498    pub status_message: Option<String>, // Make nullable - can be null
499    pub forkable: bool,
500    pub project: Project,
501    pub public: bool,
502}
503
504/// Project information in pull request context
505#[derive(Debug, Clone, Deserialize, Serialize)]
506pub struct Project {
507    pub id: u64,
508    pub key: String,
509    pub name: String,
510    pub description: Option<String>,
511    pub public: bool,
512    #[serde(rename = "type")]
513    pub project_type: String,
514}
515
516/// Pull request links
517#[derive(Debug, Clone, Deserialize, Serialize)]
518pub struct PullRequestLinks {
519    #[serde(rename = "self")]
520    pub self_link: Vec<SelfLink>,
521}
522
523/// Self link
524#[derive(Debug, Clone, Deserialize, Serialize)]
525pub struct SelfLink {
526    pub href: String,
527}
528
529/// Pull request participant
530#[derive(Debug, Clone, Deserialize, Serialize)]
531pub struct Participant {
532    pub user: User,
533    pub role: ParticipantRole,
534    pub approved: bool,
535    pub status: ParticipantStatus,
536}
537
538/// User information
539#[derive(Debug, Clone, Deserialize, Serialize)]
540pub struct User {
541    pub name: String,
542    #[serde(rename = "displayName")]
543    pub display_name: Option<String>, // Make nullable - can be null for service accounts
544    #[serde(rename = "emailAddress")]
545    pub email_address: Option<String>, // Make nullable - can be null for some users
546    pub active: bool,
547    pub slug: Option<String>, // Make nullable - can be null in some cases
548}
549
550/// Pull request state
551#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
552#[serde(rename_all = "UPPERCASE")]
553pub enum PullRequestState {
554    Open,
555    Merged,
556    Declined,
557}
558
559impl PullRequestState {
560    pub fn as_str(&self) -> &'static str {
561        match self {
562            Self::Open => "OPEN",
563            Self::Merged => "MERGED",
564            Self::Declined => "DECLINED",
565        }
566    }
567}
568
569/// Participant role
570#[derive(Debug, Clone, Deserialize, Serialize)]
571#[serde(rename_all = "UPPERCASE")]
572pub enum ParticipantRole {
573    Author,
574    Reviewer,
575    Participant,
576}
577
578/// Participant status
579#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
580#[serde(rename_all = "UPPERCASE")]
581pub enum ParticipantStatus {
582    Approved,
583    Unapproved,
584    #[serde(rename = "NEEDS_WORK")]
585    NeedsWork,
586}
587
588/// Paginated pull request results
589#[derive(Debug, Deserialize)]
590pub struct PullRequestPage {
591    pub size: u32,
592    pub limit: u32,
593    #[serde(rename = "isLastPage")]
594    pub is_last_page: bool,
595    pub values: Vec<PullRequest>,
596    pub start: u32,
597    #[serde(rename = "nextPageStart")]
598    pub next_page_start: Option<u32>,
599}
600
601impl PullRequest {
602    /// Get the pull request URL
603    pub fn web_url(&self) -> Option<String> {
604        self.links.self_link.first().map(|link| link.href.clone())
605    }
606
607    /// Check if the pull request is still open
608    pub fn is_open(&self) -> bool {
609        self.state == PullRequestState::Open && self.open && !self.closed
610    }
611
612    /// Get the created date as a DateTime
613    pub fn created_at(&self) -> DateTime<Utc> {
614        DateTime::from_timestamp(self.created_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
615    }
616
617    /// Get the updated date as a DateTime
618    pub fn updated_at(&self) -> DateTime<Utc> {
619        DateTime::from_timestamp(self.updated_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
620    }
621}
622
623/// Enhanced pull request status with mergability information
624#[derive(Debug, Clone, Deserialize, Serialize)]
625pub struct PullRequestStatus {
626    pub pr: PullRequest,
627    pub mergeable: Option<bool>,
628    pub mergeable_details: Option<MergeabilityDetails>,
629    pub participants: Vec<Participant>,
630    pub build_status: Option<BuildStatus>,
631    pub review_status: ReviewStatus,
632    pub conflicts: Option<Vec<String>>,
633}
634
635/// Build status from CI/CD systems
636#[derive(Debug, Clone, Deserialize, Serialize)]
637pub struct BuildStatus {
638    pub state: BuildState,
639    pub url: Option<String>,
640    pub description: Option<String>,
641    pub context: Option<String>,
642}
643
644/// Build state enum
645#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
646#[serde(rename_all = "UPPERCASE")]
647pub enum BuildState {
648    Successful,
649    Failed,
650    InProgress,
651    Cancelled,
652    Unknown,
653}
654
655/// Review status summary
656#[derive(Debug, Clone, Deserialize, Serialize)]
657pub struct ReviewStatus {
658    pub required_approvals: usize,
659    pub current_approvals: usize,
660    pub needs_work_count: usize,
661    pub can_merge: bool,
662    pub missing_reviewers: Vec<String>,
663}
664
665/// Response for participants endpoint
666#[derive(Debug, Deserialize)]
667struct ParticipantsResponse {
668    pub values: Vec<Participant>,
669}
670
671/// Response for mergeability check
672#[derive(Debug, Deserialize)]
673#[allow(dead_code)]
674struct MergeabilityResponse {
675    #[serde(rename = "canMerge")]
676    pub can_merge: bool,
677    pub conflicted: Option<bool>,
678}
679
680/// Response for build status
681#[derive(Debug, Deserialize)]
682struct BuildStatusResponse {
683    pub values: Vec<BuildInfo>,
684}
685
686/// Build information from Bitbucket
687#[derive(Debug, Deserialize)]
688struct BuildInfo {
689    pub state: BuildState,
690    pub name: Option<String>,
691    pub url: Option<String>,
692    pub description: Option<String>,
693}
694
695/// Response for diff endpoint
696#[derive(Debug, Deserialize)]
697#[allow(dead_code)]
698struct DiffResponse {
699    pub diffs: Vec<serde_json::Value>, // Simplified
700}
701
702impl PullRequestStatus {
703    /// Get a summary status for display
704    pub fn get_display_status(&self) -> String {
705        if self.pr.state != PullRequestState::Open {
706            return format!("{:?}", self.pr.state).to_uppercase();
707        }
708
709        let mut status_parts = Vec::new();
710
711        // Build status
712        if let Some(build) = &self.build_status {
713            match build.state {
714                BuildState::Successful => status_parts.push("✅ Builds"),
715                BuildState::Failed => status_parts.push("❌ Builds"),
716                BuildState::InProgress => status_parts.push("🔄 Building"),
717                _ => status_parts.push("⚪ Builds"),
718            }
719        }
720
721        // Review status
722        if self.review_status.can_merge {
723            status_parts.push("✅ Reviews");
724        } else if self.review_status.needs_work_count > 0 {
725            status_parts.push("❌ Reviews");
726        } else {
727            status_parts.push("⏳ Reviews");
728        }
729
730        // Merge conflicts
731        if let Some(mergeable) = self.mergeable {
732            if mergeable {
733                status_parts.push("✅ Mergeable");
734            } else {
735                status_parts.push("❌ Conflicts");
736            }
737        }
738
739        if status_parts.is_empty() {
740            "🔄 Open".to_string()
741        } else {
742            status_parts.join(" | ")
743        }
744    }
745
746    /// Check if this PR is ready to land/merge
747    pub fn is_ready_to_land(&self) -> bool {
748        self.pr.state == PullRequestState::Open
749            && self.review_status.can_merge
750            && self.mergeable.unwrap_or(false)
751            && matches!(
752                self.build_status.as_ref().map(|b| &b.state),
753                Some(BuildState::Successful) | None
754            )
755    }
756
757    /// Get detailed reasons why PR cannot be merged
758    pub fn get_blocking_reasons(&self) -> Vec<String> {
759        let mut reasons = Vec::new();
760
761        // 🎯 SERVER-SIDE MERGE CHECKS (Most Important)
762        // These are authoritative from Bitbucket Server and include:
763        // - Required approvals, build checks, branch permissions
764        // - Code Insights, required builds, custom merge checks
765        // - Task completion, default reviewers, etc.
766        if let Some(mergeable_details) = &self.mergeable_details {
767            if !mergeable_details.can_merge {
768                // Add specific server-side blocking reasons
769                for reason in &mergeable_details.blocking_reasons {
770                    reasons.push(format!("🔒 Server Check: {reason}"));
771                }
772
773                // If no specific reasons but still not mergeable
774                if mergeable_details.blocking_reasons.is_empty() {
775                    reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
776                }
777            }
778        } else if self.mergeable == Some(false) {
779            // Fallback if we don't have detailed info
780            reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
781        }
782
783        // ❌ PR State Check
784        if !self.pr.is_open() {
785            reasons.push(format!(
786                "❌ PR Status: Pull request is {}",
787                self.pr.state.as_str()
788            ));
789        }
790
791        // 🔄 Build Status Check
792        if let Some(build_status) = &self.build_status {
793            match build_status.state {
794                BuildState::Failed => reasons.push("❌ Build Status: Build failed".to_string()),
795                BuildState::InProgress => {
796                    reasons.push("⏳ Build Status: Build in progress".to_string())
797                }
798                BuildState::Cancelled => {
799                    reasons.push("❌ Build Status: Build cancelled".to_string())
800                }
801                BuildState::Unknown => {
802                    reasons.push("❓ Build Status: Build status unknown".to_string())
803                }
804                BuildState::Successful => {} // No blocking reason
805            }
806        }
807
808        // 👥 Review Status Check (supplementary to server checks)
809        if !self.review_status.can_merge {
810            if self.review_status.current_approvals < self.review_status.required_approvals {
811                reasons.push(format!(
812                    "👥 Review Status: Need {} more approval{} ({}/{})",
813                    self.review_status.required_approvals - self.review_status.current_approvals,
814                    if self.review_status.required_approvals - self.review_status.current_approvals
815                        == 1
816                    {
817                        ""
818                    } else {
819                        "s"
820                    },
821                    self.review_status.current_approvals,
822                    self.review_status.required_approvals
823                ));
824            }
825
826            if self.review_status.needs_work_count > 0 {
827                reasons.push(format!(
828                    "👥 Review Status: {} reviewer{} requested changes",
829                    self.review_status.needs_work_count,
830                    if self.review_status.needs_work_count == 1 {
831                        ""
832                    } else {
833                        "s"
834                    }
835                ));
836            }
837
838            if !self.review_status.missing_reviewers.is_empty() {
839                reasons.push(format!(
840                    "👥 Review Status: Missing approval from: {}",
841                    self.review_status.missing_reviewers.join(", ")
842                ));
843            }
844        }
845
846        // ⚠️ Merge Conflicts Check
847        if let Some(conflicts) = &self.conflicts {
848            if !conflicts.is_empty() {
849                reasons.push(format!(
850                    "⚠️ Merge Conflicts: {} file{} with conflicts",
851                    conflicts.len(),
852                    if conflicts.len() == 1 { "" } else { "s" }
853                ));
854            }
855        }
856
857        reasons
858    }
859
860    /// Check if this PR can be auto-merged based on conditions
861    pub fn can_auto_merge(&self, conditions: &AutoMergeConditions) -> bool {
862        // ✅ Check if PR is open
863        if !self.pr.is_open() {
864            return false;
865        }
866
867        // ✅ Author allowlist check (if specified)
868        if let Some(allowed_authors) = &conditions.allowed_authors {
869            if !allowed_authors.contains(&self.pr.author.user.name) {
870                return false;
871            }
872        }
873
874        // ✅ Use Bitbucket's authoritative merge endpoint result
875        // This checks all server-side requirements: approvals, builds, conflicts, etc.
876        self.mergeable.unwrap_or(false)
877    }
878}
879
880/// Auto-merge configuration
881#[derive(Debug, Clone)]
882pub struct AutoMergeConditions {
883    pub merge_strategy: MergeStrategy,
884    pub wait_for_builds: bool,
885    pub build_timeout: Duration,
886    pub allowed_authors: Option<Vec<String>>, // Only auto-merge from trusted authors
887}
888
889impl Default for AutoMergeConditions {
890    fn default() -> Self {
891        Self {
892            merge_strategy: MergeStrategy::Squash,
893            wait_for_builds: true,
894            build_timeout: Duration::from_secs(1800), // 30 minutes
895            allowed_authors: None,
896        }
897    }
898}
899
900/// Merge strategy for pull requests
901#[derive(Debug, Clone, Serialize, Deserialize)]
902#[serde(rename_all = "kebab-case")]
903pub enum MergeStrategy {
904    #[serde(rename = "merge-commit")]
905    Merge,
906    #[serde(rename = "squash")]
907    Squash,
908    #[serde(rename = "fast-forward")]
909    FastForward,
910}
911
912impl MergeStrategy {
913    pub fn get_commit_message(&self, pr: &PullRequest) -> Option<String> {
914        match self {
915            MergeStrategy::Squash => Some(format!(
916                "{}\n\n{}",
917                pr.title,
918                pr.description.as_deref().unwrap_or("")
919            )),
920            _ => None, // Use Bitbucket default
921        }
922    }
923}
924
925/// Result of auto-merge attempt
926#[derive(Debug)]
927pub enum AutoMergeResult {
928    Merged {
929        pr: Box<PullRequest>,
930        merge_strategy: MergeStrategy,
931    },
932    NotReady {
933        blocking_reasons: Vec<String>,
934    },
935    Failed {
936        error: String,
937    },
938}
939
940/// Merge request payload for Bitbucket Server
941#[derive(Debug, Serialize)]
942struct MergePullRequestRequest {
943    version: u64,
944    #[serde(skip_serializing_if = "Option::is_none")]
945    message: Option<String>,
946    #[serde(rename = "strategy")]
947    strategy: MergeStrategy,
948}
949
950/// Mergeability details
951#[derive(Debug, Clone, Deserialize, Serialize)]
952pub struct MergeabilityDetails {
953    pub can_merge: bool,
954    pub conflicted: bool,
955    pub blocking_reasons: Vec<String>,
956    pub server_enforced: bool,
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use std::time::Duration;
963
964    // Helper function to create a mock pull request
965    fn create_test_pull_request(id: u64, state: PullRequestState) -> PullRequest {
966        let is_open = state == PullRequestState::Open;
967        PullRequest {
968            id,
969            version: 1,
970            title: "Test PR".to_string(),
971            description: Some("Test description".to_string()),
972            state: state.clone(),
973            open: is_open,
974            closed: !is_open,
975            created_date: 1700000000000, // Mock timestamp
976            updated_date: 1700000000000,
977            from_ref: PullRequestRef {
978                id: "refs/heads/feature".to_string(),
979                display_id: "feature".to_string(),
980                latest_commit: "abc123".to_string(),
981                repository: create_test_repository(),
982            },
983            to_ref: PullRequestRef {
984                id: "refs/heads/main".to_string(),
985                display_id: "main".to_string(),
986                latest_commit: "def456".to_string(),
987                repository: create_test_repository(),
988            },
989            locked: false,
990            author: create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved),
991            links: PullRequestLinks {
992                self_link: vec![SelfLink {
993                    href: format!(
994                        "http://bitbucket.local/projects/TEST/repos/test/pull-requests/{id}"
995                    ),
996                }],
997            },
998        }
999    }
1000
1001    fn create_test_repository() -> Repository {
1002        Repository {
1003            id: 1,
1004            name: "test-repo".to_string(),
1005            slug: "test-repo".to_string(),
1006            scm_id: "git".to_string(),
1007            state: "AVAILABLE".to_string(),
1008            status_message: Some("Available".to_string()),
1009            forkable: true,
1010            project: Project {
1011                id: 1,
1012                key: "TEST".to_string(),
1013                name: "Test Project".to_string(),
1014                description: Some("Test project description".to_string()),
1015                public: false,
1016                project_type: "NORMAL".to_string(),
1017            },
1018            public: false,
1019        }
1020    }
1021
1022    fn create_test_participant(role: ParticipantRole, status: ParticipantStatus) -> Participant {
1023        Participant {
1024            user: User {
1025                name: "testuser".to_string(),
1026                display_name: Some("Test User".to_string()),
1027                email_address: Some("test@example.com".to_string()),
1028                active: true,
1029                slug: Some("testuser".to_string()),
1030            },
1031            role,
1032            approved: status == ParticipantStatus::Approved,
1033            status,
1034        }
1035    }
1036
1037    fn create_test_build_status(state: BuildState) -> BuildStatus {
1038        BuildStatus {
1039            state,
1040            url: Some("http://ci.example.com/build/123".to_string()),
1041            description: Some("Test build".to_string()),
1042            context: Some("CI/CD".to_string()),
1043        }
1044    }
1045
1046    #[test]
1047    fn test_pull_request_state_serialization() {
1048        assert_eq!(PullRequestState::Open.as_str(), "OPEN");
1049        assert_eq!(PullRequestState::Merged.as_str(), "MERGED");
1050        assert_eq!(PullRequestState::Declined.as_str(), "DECLINED");
1051    }
1052
1053    #[test]
1054    fn test_pull_request_is_open() {
1055        let open_pr = create_test_pull_request(1, PullRequestState::Open);
1056        assert!(open_pr.is_open());
1057
1058        let merged_pr = create_test_pull_request(2, PullRequestState::Merged);
1059        assert!(!merged_pr.is_open());
1060
1061        let declined_pr = create_test_pull_request(3, PullRequestState::Declined);
1062        assert!(!declined_pr.is_open());
1063    }
1064
1065    #[test]
1066    fn test_pull_request_web_url() {
1067        let pr = create_test_pull_request(123, PullRequestState::Open);
1068        let url = pr.web_url();
1069        assert!(url.is_some());
1070        assert_eq!(
1071            url.unwrap(),
1072            "http://bitbucket.local/projects/TEST/repos/test/pull-requests/123"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_merge_strategy_conversion() {
1078        let squash = MergeStrategy::Squash;
1079        let merge = MergeStrategy::Merge;
1080        let ff = MergeStrategy::FastForward;
1081
1082        // Test that strategies can be created and compared
1083        assert!(matches!(squash, MergeStrategy::Squash));
1084        assert!(matches!(merge, MergeStrategy::Merge));
1085        assert!(matches!(ff, MergeStrategy::FastForward));
1086    }
1087
1088    #[test]
1089    fn test_merge_strategy_commit_message() {
1090        let pr = create_test_pull_request(1, PullRequestState::Open);
1091
1092        let squash_strategy = MergeStrategy::Squash;
1093        let message = squash_strategy.get_commit_message(&pr);
1094        assert!(message.is_some());
1095        assert!(message.unwrap().contains("Test PR"));
1096
1097        let merge_strategy = MergeStrategy::Merge;
1098        let message = merge_strategy.get_commit_message(&pr);
1099        assert!(message.is_none()); // Merge strategy uses Bitbucket default
1100
1101        let ff_strategy = MergeStrategy::FastForward;
1102        let message = ff_strategy.get_commit_message(&pr);
1103        assert!(message.is_none()); // Fast-forward doesn't create new commit message
1104    }
1105
1106    #[test]
1107    fn test_auto_merge_conditions_default() {
1108        let conditions = AutoMergeConditions::default();
1109
1110        assert!(conditions.wait_for_builds); // Default is true for auto-merge safety
1111        assert_eq!(conditions.build_timeout.as_secs(), 1800); // 30 minutes
1112        assert!(conditions.allowed_authors.is_none());
1113        assert!(matches!(conditions.merge_strategy, MergeStrategy::Squash));
1114    }
1115
1116    #[test]
1117    fn test_auto_merge_conditions_custom() {
1118        let conditions = AutoMergeConditions {
1119            merge_strategy: MergeStrategy::Merge,
1120            wait_for_builds: false,
1121            build_timeout: Duration::from_secs(3600),
1122            allowed_authors: Some(vec!["trusted-user".to_string()]),
1123        };
1124
1125        assert!(matches!(conditions.merge_strategy, MergeStrategy::Merge));
1126        assert!(!conditions.wait_for_builds);
1127        assert_eq!(conditions.build_timeout.as_secs(), 3600);
1128        assert!(conditions.allowed_authors.is_some());
1129    }
1130
1131    #[test]
1132    fn test_pull_request_status_ready_to_land() {
1133        let pr = create_test_pull_request(1, PullRequestState::Open);
1134        let participants = vec![create_test_participant(
1135            ParticipantRole::Reviewer,
1136            ParticipantStatus::Approved,
1137        )];
1138        let review_status = ReviewStatus {
1139            required_approvals: 1,
1140            current_approvals: 1,
1141            needs_work_count: 0,
1142            can_merge: true,
1143            missing_reviewers: vec![],
1144        };
1145
1146        let status = PullRequestStatus {
1147            pr,
1148            mergeable: Some(true),
1149            mergeable_details: None,
1150            participants,
1151            build_status: Some(create_test_build_status(BuildState::Successful)),
1152            review_status,
1153            conflicts: None,
1154        };
1155
1156        assert!(status.is_ready_to_land());
1157    }
1158
1159    #[test]
1160    fn test_pull_request_status_not_ready_to_land() {
1161        let pr = create_test_pull_request(1, PullRequestState::Open);
1162        let participants = vec![create_test_participant(
1163            ParticipantRole::Reviewer,
1164            ParticipantStatus::Unapproved,
1165        )];
1166        let review_status = ReviewStatus {
1167            required_approvals: 1,
1168            current_approvals: 0,
1169            needs_work_count: 0,
1170            can_merge: false,
1171            missing_reviewers: vec!["reviewer".to_string()],
1172        };
1173
1174        let status = PullRequestStatus {
1175            pr,
1176            mergeable: Some(false),
1177            mergeable_details: None,
1178            participants,
1179            build_status: Some(create_test_build_status(BuildState::Failed)),
1180            review_status,
1181            conflicts: Some(vec!["Conflict in file.txt".to_string()]),
1182        };
1183
1184        assert!(!status.is_ready_to_land());
1185    }
1186
1187    #[test]
1188    fn test_pull_request_status_blocking_reasons() {
1189        // Test PR with failed build
1190        let pr_status = PullRequestStatus {
1191            pr: create_test_pull_request(1, PullRequestState::Open),
1192            mergeable: Some(true),
1193            mergeable_details: None,
1194            participants: vec![create_test_participant(
1195                ParticipantRole::Author,
1196                ParticipantStatus::Approved,
1197            )],
1198            build_status: Some(create_test_build_status(BuildState::Failed)),
1199            review_status: ReviewStatus {
1200                required_approvals: 1,
1201                current_approvals: 0, // Needs approval
1202                needs_work_count: 0,
1203                can_merge: false,
1204                missing_reviewers: vec!["reviewer1".to_string()],
1205            },
1206            conflicts: None,
1207        };
1208
1209        let blocking_reasons = pr_status.get_blocking_reasons();
1210
1211        // Verify it detects multiple blocking reasons
1212        assert!(!blocking_reasons.is_empty());
1213
1214        // Check for build failure (actual format is "Build failed")
1215        assert!(blocking_reasons.iter().any(|r| r.contains("Build failed")));
1216
1217        // Check for approval requirement
1218        assert!(blocking_reasons.iter().any(|r| r.contains("more approval")));
1219    }
1220
1221    #[test]
1222    fn test_pull_request_status_can_auto_merge() {
1223        let pr = create_test_pull_request(1, PullRequestState::Open);
1224        let participants = vec![create_test_participant(
1225            ParticipantRole::Reviewer,
1226            ParticipantStatus::Approved,
1227        )];
1228        let review_status = ReviewStatus {
1229            required_approvals: 1,
1230            current_approvals: 1,
1231            needs_work_count: 0,
1232            can_merge: true,
1233            missing_reviewers: vec![],
1234        };
1235
1236        let status = PullRequestStatus {
1237            pr,
1238            mergeable: Some(true),
1239            mergeable_details: None,
1240            participants,
1241            build_status: Some(create_test_build_status(BuildState::Successful)),
1242            review_status,
1243            conflicts: None,
1244        };
1245
1246        let conditions = AutoMergeConditions::default();
1247        assert!(status.can_auto_merge(&conditions));
1248
1249        // Test with author allowlist (should pass since PR author is "testuser")
1250        let allowlist_conditions = AutoMergeConditions {
1251            allowed_authors: Some(vec!["testuser".to_string()]),
1252            ..Default::default()
1253        };
1254        assert!(status.can_auto_merge(&allowlist_conditions));
1255
1256        // Test with non-matching mergeable state
1257        let mut status_not_mergeable = status.clone();
1258        status_not_mergeable.mergeable = Some(false);
1259        assert!(!status_not_mergeable.can_auto_merge(&conditions));
1260    }
1261
1262    #[test]
1263    fn test_build_state_variants() {
1264        // Test that all build states can be created
1265        let _successful = BuildState::Successful;
1266        let _failed = BuildState::Failed;
1267        let _in_progress = BuildState::InProgress;
1268        let _cancelled = BuildState::Cancelled;
1269        let _unknown = BuildState::Unknown;
1270
1271        // Test works if it compiles
1272        // Test passes if we reach this point without errors
1273    }
1274
1275    #[test]
1276    fn test_review_status_calculations() {
1277        let review_status = ReviewStatus {
1278            required_approvals: 2,
1279            current_approvals: 1,
1280            needs_work_count: 0,
1281            can_merge: false,
1282            missing_reviewers: vec!["reviewer2".to_string()],
1283        };
1284
1285        assert_eq!(review_status.required_approvals, 2);
1286        assert_eq!(review_status.current_approvals, 1);
1287        assert_eq!(review_status.needs_work_count, 0);
1288        assert!(!review_status.can_merge);
1289        assert_eq!(review_status.missing_reviewers.len(), 1);
1290    }
1291
1292    #[test]
1293    fn test_auto_merge_result_variants() {
1294        let pr = create_test_pull_request(1, PullRequestState::Merged);
1295
1296        // Test successful merge result
1297        let merged_result = AutoMergeResult::Merged {
1298            pr: Box::new(pr.clone()),
1299            merge_strategy: MergeStrategy::Squash,
1300        };
1301        assert!(matches!(merged_result, AutoMergeResult::Merged { .. }));
1302
1303        // Test not ready result
1304        let not_ready_result = AutoMergeResult::NotReady {
1305            blocking_reasons: vec!["Missing approvals".to_string()],
1306        };
1307        assert!(matches!(not_ready_result, AutoMergeResult::NotReady { .. }));
1308
1309        // Test failed result
1310        let failed_result = AutoMergeResult::Failed {
1311            error: "Network error".to_string(),
1312        };
1313        assert!(matches!(failed_result, AutoMergeResult::Failed { .. }));
1314    }
1315
1316    #[test]
1317    fn test_participant_roles_and_status() {
1318        let author = create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved);
1319        assert!(matches!(author.role, ParticipantRole::Author));
1320        assert!(author.approved);
1321
1322        let reviewer =
1323            create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::Unapproved);
1324        assert!(matches!(reviewer.role, ParticipantRole::Reviewer));
1325        assert!(!reviewer.approved);
1326
1327        let needs_work =
1328            create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::NeedsWork);
1329        assert!(matches!(needs_work.status, ParticipantStatus::NeedsWork));
1330        assert!(!needs_work.approved);
1331    }
1332
1333    #[test]
1334    fn test_polling_frequency_constant() {
1335        // Test that the polling frequency is 30 seconds as documented
1336        use std::time::Duration;
1337
1338        let polling_interval = Duration::from_secs(30);
1339        assert_eq!(polling_interval.as_secs(), 30);
1340
1341        // Verify it's reasonable (between 10 seconds and 1 minute)
1342        assert!(polling_interval.as_secs() >= 10);
1343        assert!(polling_interval.as_secs() <= 60);
1344    }
1345}