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    ///
309    /// NOTE: Currently unimplemented - always returns empty list.
310    /// Proper implementation would parse diff for conflict markers or use
311    /// Bitbucket's merge API to detect conflicts. In practice, the `mergeable`
312    /// field from `check_mergeable_detailed()` is more reliable for detecting conflicts.
313    pub async fn get_conflicts(&self, pr_id: u64) -> Result<Vec<String>> {
314        // Conflicts are detected via the mergeable API (check_mergeable_detailed)
315        // which provides server-side conflict detection. This function is kept
316        // for future enhancement but is not currently needed.
317        let _ = pr_id; // Avoid unused parameter warning
318        Ok(Vec::new())
319    }
320
321    /// Calculate review status based on participants
322    fn calculate_review_status(&self, participants: &[Participant]) -> Result<ReviewStatus> {
323        let mut current_approvals = 0;
324        let mut needs_work_count = 0;
325        let mut missing_reviewers = Vec::new();
326
327        for participant in participants {
328            match participant.status {
329                ParticipantStatus::Approved => current_approvals += 1,
330                ParticipantStatus::NeedsWork => needs_work_count += 1,
331                ParticipantStatus::Unapproved => {
332                    if matches!(participant.role, ParticipantRole::Reviewer) {
333                        missing_reviewers.push(
334                            participant
335                                .user
336                                .display_name
337                                .clone()
338                                .unwrap_or_else(|| participant.user.name.clone()),
339                        );
340                    }
341                }
342            }
343        }
344
345        // Note: required_approvals is kept for API compatibility but is not accurate.
346        // The REAL approval requirements are enforced by Bitbucket Server via the
347        // /merge endpoint (check_mergeable_detailed), which checks:
348        // - Repository approval requirements (configured in Bitbucket settings)
349        // - Default reviewer approvals
350        // - Build status requirements
351        // - Branch permissions
352        // - Task completion, Code Insights, custom merge checks
353        //
354        // We set this to 0 to indicate "unknown" - callers should rely on
355        // can_merge and the server's mergeable checks, not this field.
356        let can_merge = current_approvals > 0 && needs_work_count == 0;
357
358        Ok(ReviewStatus {
359            required_approvals: 0, // Unknown - see comment above
360            current_approvals,
361            needs_work_count,
362            can_merge,
363            missing_reviewers,
364        })
365    }
366
367    /// Merge a pull request using Bitbucket Server API
368    pub async fn merge_pull_request(
369        &self,
370        pr_id: u64,
371        merge_strategy: MergeStrategy,
372    ) -> Result<PullRequest> {
373        let pr = self.get_pull_request(pr_id).await?;
374
375        let merge_request = MergePullRequestRequest {
376            version: pr.version,
377            message: merge_strategy.get_commit_message(&pr),
378            strategy: merge_strategy,
379        };
380
381        self.client
382            .post(&format!("pull-requests/{pr_id}/merge"), &merge_request)
383            .await
384    }
385
386    /// Auto-merge a pull request if conditions are met
387    pub async fn auto_merge_if_ready(
388        &self,
389        pr_id: u64,
390        conditions: &AutoMergeConditions,
391    ) -> Result<AutoMergeResult> {
392        let status = self.get_pull_request_status(pr_id).await?;
393
394        if !status.can_auto_merge(conditions) {
395            return Ok(AutoMergeResult::NotReady {
396                blocking_reasons: status.get_blocking_reasons(),
397            });
398        }
399
400        // Wait for any pending builds if required
401        if conditions.wait_for_builds {
402            self.wait_for_builds(pr_id, conditions.build_timeout)
403                .await?;
404        }
405
406        // Perform the merge
407        let merged_pr = self
408            .merge_pull_request(pr_id, conditions.merge_strategy.clone())
409            .await?;
410
411        Ok(AutoMergeResult::Merged {
412            pr: Box::new(merged_pr),
413            merge_strategy: conditions.merge_strategy.clone(),
414        })
415    }
416
417    /// Wait for builds to complete with timeout
418    async fn wait_for_builds(&self, pr_id: u64, timeout: Duration) -> Result<()> {
419        use tokio::time::{sleep, timeout as tokio_timeout};
420
421        tokio_timeout(timeout, async {
422            loop {
423                let build_status = self.get_build_status(pr_id).await?;
424
425                match build_status.state {
426                    BuildState::Successful => return Ok(()),
427                    BuildState::Failed | BuildState::Cancelled => {
428                        return Err(CascadeError::bitbucket(format!(
429                            "Build failed: {}",
430                            build_status.description.unwrap_or_default()
431                        )));
432                    }
433                    BuildState::InProgress => {
434                        sleep(Duration::from_secs(30)).await; // Poll every 30s
435                        continue;
436                    }
437                    BuildState::Unknown => {
438                        return Err(CascadeError::bitbucket("Build status unknown".to_string()));
439                    }
440                }
441            }
442        })
443        .await
444        .map_err(|_| CascadeError::bitbucket("Build timeout exceeded".to_string()))?
445    }
446}
447
448/// Request to create a new pull request
449#[derive(Debug, Serialize, Deserialize, Clone)]
450pub struct CreatePullRequestRequest {
451    pub title: String,
452    pub description: Option<String>,
453    #[serde(rename = "fromRef")]
454    pub from_ref: PullRequestRef,
455    #[serde(rename = "toRef")]
456    pub to_ref: PullRequestRef,
457    #[serde(rename = "isDraft")]
458    pub draft: bool,
459}
460
461/// Pull request data structure
462#[derive(Debug, Clone, Deserialize, Serialize)]
463pub struct PullRequest {
464    pub id: u64,
465    pub version: u64,
466    pub title: String,
467    pub description: Option<String>,
468    pub state: PullRequestState,
469    pub open: bool,
470    pub closed: bool,
471    #[serde(rename = "createdDate")]
472    pub created_date: u64,
473    #[serde(rename = "updatedDate")]
474    pub updated_date: u64,
475    #[serde(rename = "fromRef")]
476    pub from_ref: PullRequestRef,
477    #[serde(rename = "toRef")]
478    pub to_ref: PullRequestRef,
479    pub locked: bool,
480    pub author: Participant,
481    pub links: PullRequestLinks,
482}
483
484/// Pull request reference (branch information)
485#[derive(Debug, Clone, Deserialize, Serialize)]
486pub struct PullRequestRef {
487    pub id: String,
488    #[serde(rename = "displayId")]
489    pub display_id: String,
490    #[serde(rename = "latestCommit")]
491    pub latest_commit: String,
492    pub repository: Repository,
493}
494
495/// Repository information in pull request context
496#[derive(Debug, Clone, Deserialize, Serialize)]
497pub struct Repository {
498    pub id: u64,
499    pub name: String,
500    pub slug: String,
501    #[serde(rename = "scmId")]
502    pub scm_id: String,
503    pub state: String,
504    #[serde(rename = "statusMessage")]
505    pub status_message: Option<String>, // Make nullable - can be null
506    pub forkable: bool,
507    pub project: Project,
508    pub public: bool,
509}
510
511/// Project information in pull request context
512#[derive(Debug, Clone, Deserialize, Serialize)]
513pub struct Project {
514    pub id: u64,
515    pub key: String,
516    pub name: String,
517    pub description: Option<String>,
518    pub public: bool,
519    #[serde(rename = "type")]
520    pub project_type: String,
521}
522
523/// Pull request links
524#[derive(Debug, Clone, Deserialize, Serialize)]
525pub struct PullRequestLinks {
526    #[serde(rename = "self")]
527    pub self_link: Vec<SelfLink>,
528}
529
530/// Self link
531#[derive(Debug, Clone, Deserialize, Serialize)]
532pub struct SelfLink {
533    pub href: String,
534}
535
536/// Pull request participant
537#[derive(Debug, Clone, Deserialize, Serialize)]
538pub struct Participant {
539    pub user: User,
540    pub role: ParticipantRole,
541    pub approved: bool,
542    pub status: ParticipantStatus,
543}
544
545/// User information
546#[derive(Debug, Clone, Deserialize, Serialize)]
547pub struct User {
548    pub name: String,
549    #[serde(rename = "displayName")]
550    pub display_name: Option<String>, // Make nullable - can be null for service accounts
551    #[serde(rename = "emailAddress")]
552    pub email_address: Option<String>, // Make nullable - can be null for some users
553    pub active: bool,
554    pub slug: Option<String>, // Make nullable - can be null in some cases
555}
556
557/// Pull request state
558#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
559#[serde(rename_all = "UPPERCASE")]
560pub enum PullRequestState {
561    Open,
562    Merged,
563    Declined,
564}
565
566impl PullRequestState {
567    pub fn as_str(&self) -> &'static str {
568        match self {
569            Self::Open => "OPEN",
570            Self::Merged => "MERGED",
571            Self::Declined => "DECLINED",
572        }
573    }
574}
575
576/// Participant role
577#[derive(Debug, Clone, Deserialize, Serialize)]
578#[serde(rename_all = "UPPERCASE")]
579pub enum ParticipantRole {
580    Author,
581    Reviewer,
582    Participant,
583}
584
585/// Participant status
586#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
587#[serde(rename_all = "UPPERCASE")]
588pub enum ParticipantStatus {
589    Approved,
590    Unapproved,
591    #[serde(rename = "NEEDS_WORK")]
592    NeedsWork,
593}
594
595/// Paginated pull request results
596#[derive(Debug, Deserialize)]
597pub struct PullRequestPage {
598    pub size: u32,
599    pub limit: u32,
600    #[serde(rename = "isLastPage")]
601    pub is_last_page: bool,
602    pub values: Vec<PullRequest>,
603    pub start: u32,
604    #[serde(rename = "nextPageStart")]
605    pub next_page_start: Option<u32>,
606}
607
608impl PullRequest {
609    /// Get the pull request URL
610    pub fn web_url(&self) -> Option<String> {
611        self.links.self_link.first().map(|link| link.href.clone())
612    }
613
614    /// Check if the pull request is still open
615    pub fn is_open(&self) -> bool {
616        self.state == PullRequestState::Open && self.open && !self.closed
617    }
618
619    /// Get the created date as a DateTime
620    pub fn created_at(&self) -> DateTime<Utc> {
621        DateTime::from_timestamp(self.created_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
622    }
623
624    /// Get the updated date as a DateTime
625    pub fn updated_at(&self) -> DateTime<Utc> {
626        DateTime::from_timestamp(self.updated_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
627    }
628}
629
630/// Enhanced pull request status with mergability information
631#[derive(Debug, Clone, Deserialize, Serialize)]
632pub struct PullRequestStatus {
633    pub pr: PullRequest,
634    pub mergeable: Option<bool>,
635    pub mergeable_details: Option<MergeabilityDetails>,
636    pub participants: Vec<Participant>,
637    pub build_status: Option<BuildStatus>,
638    pub review_status: ReviewStatus,
639    pub conflicts: Option<Vec<String>>,
640}
641
642/// Build status from CI/CD systems
643#[derive(Debug, Clone, Deserialize, Serialize)]
644pub struct BuildStatus {
645    pub state: BuildState,
646    pub url: Option<String>,
647    pub description: Option<String>,
648    pub context: Option<String>,
649}
650
651/// Build state enum
652#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
653#[serde(rename_all = "UPPERCASE")]
654pub enum BuildState {
655    Successful,
656    Failed,
657    InProgress,
658    Cancelled,
659    Unknown,
660}
661
662/// Review status summary
663#[derive(Debug, Clone, Deserialize, Serialize)]
664pub struct ReviewStatus {
665    pub required_approvals: usize,
666    pub current_approvals: usize,
667    pub needs_work_count: usize,
668    pub can_merge: bool,
669    pub missing_reviewers: Vec<String>,
670}
671
672/// Response for participants endpoint
673#[derive(Debug, Deserialize)]
674struct ParticipantsResponse {
675    pub values: Vec<Participant>,
676}
677
678/// Response for mergeability check
679#[derive(Debug, Deserialize)]
680#[allow(dead_code)]
681struct MergeabilityResponse {
682    #[serde(rename = "canMerge")]
683    pub can_merge: bool,
684    pub conflicted: Option<bool>,
685}
686
687/// Response for build status
688#[derive(Debug, Deserialize)]
689struct BuildStatusResponse {
690    pub values: Vec<BuildInfo>,
691}
692
693/// Build information from Bitbucket
694#[derive(Debug, Deserialize)]
695struct BuildInfo {
696    pub state: BuildState,
697    pub name: Option<String>,
698    pub url: Option<String>,
699    pub description: Option<String>,
700}
701
702/// Response for diff endpoint
703#[derive(Debug, Deserialize)]
704#[allow(dead_code)]
705struct DiffResponse {
706    pub diffs: Vec<serde_json::Value>, // Simplified
707}
708
709impl PullRequestStatus {
710    /// Get a summary status for display
711    pub fn get_display_status(&self) -> String {
712        if self.pr.state != PullRequestState::Open {
713            return format!("{:?}", self.pr.state).to_uppercase();
714        }
715
716        let mut status_parts = Vec::new();
717
718        // Build status
719        if let Some(build) = &self.build_status {
720            match build.state {
721                BuildState::Successful => status_parts.push("✅ Builds"),
722                BuildState::Failed => status_parts.push("❌ Builds"),
723                BuildState::InProgress => status_parts.push("🔄 Building"),
724                _ => status_parts.push("⚪ Builds"),
725            }
726        }
727
728        // Review status
729        if self.review_status.can_merge {
730            status_parts.push("✅ Reviews");
731        } else if self.review_status.needs_work_count > 0 {
732            status_parts.push("❌ Reviews");
733        } else {
734            status_parts.push("⏳ Reviews");
735        }
736
737        // Merge conflicts
738        if let Some(mergeable) = self.mergeable {
739            if mergeable {
740                status_parts.push("✅ Mergeable");
741            } else {
742                status_parts.push("❌ Conflicts");
743            }
744        }
745
746        if status_parts.is_empty() {
747            "🔄 Open".to_string()
748        } else {
749            status_parts.join(" | ")
750        }
751    }
752
753    /// Check if this PR is ready to land/merge
754    pub fn is_ready_to_land(&self) -> bool {
755        self.pr.state == PullRequestState::Open
756            && self.review_status.can_merge
757            && self.mergeable.unwrap_or(false)
758            && matches!(
759                self.build_status.as_ref().map(|b| &b.state),
760                Some(BuildState::Successful) | None
761            )
762    }
763
764    /// Get detailed reasons why PR cannot be merged
765    pub fn get_blocking_reasons(&self) -> Vec<String> {
766        let mut reasons = Vec::new();
767
768        // 🎯 SERVER-SIDE MERGE CHECKS (Most Important)
769        // These are authoritative from Bitbucket Server and include:
770        // - Required approvals, build checks, branch permissions
771        // - Code Insights, required builds, custom merge checks
772        // - Task completion, default reviewers, etc.
773        if let Some(mergeable_details) = &self.mergeable_details {
774            if !mergeable_details.can_merge {
775                // Add specific server-side blocking reasons
776                for reason in &mergeable_details.blocking_reasons {
777                    reasons.push(format!("🔒 Server Check: {reason}"));
778                }
779
780                // If no specific reasons but still not mergeable
781                if mergeable_details.blocking_reasons.is_empty() {
782                    reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
783                }
784            }
785        } else if self.mergeable == Some(false) {
786            // Fallback if we don't have detailed info
787            reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
788        }
789
790        // ❌ PR State Check
791        if !self.pr.is_open() {
792            reasons.push(format!(
793                "❌ PR Status: Pull request is {}",
794                self.pr.state.as_str()
795            ));
796        }
797
798        // 🔄 Build Status Check
799        if let Some(build_status) = &self.build_status {
800            match build_status.state {
801                BuildState::Failed => reasons.push("❌ Build Status: Build failed".to_string()),
802                BuildState::InProgress => {
803                    reasons.push("⏳ Build Status: Build in progress".to_string())
804                }
805                BuildState::Cancelled => {
806                    reasons.push("❌ Build Status: Build cancelled".to_string())
807                }
808                BuildState::Unknown => {
809                    reasons.push("❓ Build Status: Build status unknown".to_string())
810                }
811                BuildState::Successful => {} // No blocking reason
812            }
813        }
814
815        // 👥 Review Status Check (supplementary to server checks)
816        if !self.review_status.can_merge {
817            // Don't show approval count requirement since we don't know the real number
818            // The server-side checks (above) already include approval requirements
819            if self.review_status.current_approvals == 0 {
820                reasons.push("👥 Review Status: No approvals yet".to_string());
821            }
822
823            if self.review_status.needs_work_count > 0 {
824                reasons.push(format!(
825                    "👥 Review Status: {} reviewer{} requested changes",
826                    self.review_status.needs_work_count,
827                    if self.review_status.needs_work_count == 1 {
828                        ""
829                    } else {
830                        "s"
831                    }
832                ));
833            }
834
835            if !self.review_status.missing_reviewers.is_empty() {
836                reasons.push(format!(
837                    "👥 Review Status: Missing approval from: {}",
838                    self.review_status.missing_reviewers.join(", ")
839                ));
840            }
841        }
842
843        // ⚠️ Merge Conflicts Check
844        if let Some(conflicts) = &self.conflicts {
845            if !conflicts.is_empty() {
846                reasons.push(format!(
847                    "⚠️ Merge Conflicts: {} file{} with conflicts",
848                    conflicts.len(),
849                    if conflicts.len() == 1 { "" } else { "s" }
850                ));
851            }
852        }
853
854        reasons
855    }
856
857    /// Check if this PR can be auto-merged based on conditions
858    pub fn can_auto_merge(&self, conditions: &AutoMergeConditions) -> bool {
859        // ✅ Check if PR is open
860        if !self.pr.is_open() {
861            return false;
862        }
863
864        // ✅ Author allowlist check (if specified)
865        if let Some(allowed_authors) = &conditions.allowed_authors {
866            if !allowed_authors.contains(&self.pr.author.user.name) {
867                return false;
868            }
869        }
870
871        // ✅ Use Bitbucket's authoritative merge endpoint result
872        // This checks all server-side requirements: approvals, builds, conflicts, etc.
873        self.mergeable.unwrap_or(false)
874    }
875}
876
877/// Auto-merge configuration
878#[derive(Debug, Clone)]
879pub struct AutoMergeConditions {
880    pub merge_strategy: MergeStrategy,
881    pub wait_for_builds: bool,
882    pub build_timeout: Duration,
883    pub allowed_authors: Option<Vec<String>>, // Only auto-merge from trusted authors
884}
885
886impl Default for AutoMergeConditions {
887    fn default() -> Self {
888        Self {
889            merge_strategy: MergeStrategy::Squash,
890            wait_for_builds: true,
891            build_timeout: Duration::from_secs(1800), // 30 minutes
892            allowed_authors: None,
893        }
894    }
895}
896
897/// Merge strategy for pull requests
898#[derive(Debug, Clone, Serialize, Deserialize)]
899#[serde(rename_all = "kebab-case")]
900pub enum MergeStrategy {
901    #[serde(rename = "merge-commit")]
902    Merge,
903    #[serde(rename = "squash")]
904    Squash,
905    #[serde(rename = "fast-forward")]
906    FastForward,
907}
908
909impl MergeStrategy {
910    pub fn get_commit_message(&self, pr: &PullRequest) -> Option<String> {
911        match self {
912            MergeStrategy::Squash => Some(format!(
913                "{}\n\n{}",
914                pr.title,
915                pr.description.as_deref().unwrap_or("")
916            )),
917            _ => None, // Use Bitbucket default
918        }
919    }
920}
921
922/// Result of auto-merge attempt
923#[derive(Debug)]
924pub enum AutoMergeResult {
925    Merged {
926        pr: Box<PullRequest>,
927        merge_strategy: MergeStrategy,
928    },
929    NotReady {
930        blocking_reasons: Vec<String>,
931    },
932    Failed {
933        error: String,
934    },
935}
936
937/// Merge request payload for Bitbucket Server
938#[derive(Debug, Serialize)]
939struct MergePullRequestRequest {
940    version: u64,
941    #[serde(skip_serializing_if = "Option::is_none")]
942    message: Option<String>,
943    #[serde(rename = "strategy")]
944    strategy: MergeStrategy,
945}
946
947/// Mergeability details
948#[derive(Debug, Clone, Deserialize, Serialize)]
949pub struct MergeabilityDetails {
950    pub can_merge: bool,
951    pub conflicted: bool,
952    pub blocking_reasons: Vec<String>,
953    pub server_enforced: bool,
954}
955
956#[cfg(test)]
957mod tests {
958    use super::*;
959    use std::time::Duration;
960
961    // Helper function to create a mock pull request
962    fn create_test_pull_request(id: u64, state: PullRequestState) -> PullRequest {
963        let is_open = state == PullRequestState::Open;
964        PullRequest {
965            id,
966            version: 1,
967            title: "Test PR".to_string(),
968            description: Some("Test description".to_string()),
969            state: state.clone(),
970            open: is_open,
971            closed: !is_open,
972            created_date: 1700000000000, // Mock timestamp
973            updated_date: 1700000000000,
974            from_ref: PullRequestRef {
975                id: "refs/heads/feature".to_string(),
976                display_id: "feature".to_string(),
977                latest_commit: "abc123".to_string(),
978                repository: create_test_repository(),
979            },
980            to_ref: PullRequestRef {
981                id: "refs/heads/main".to_string(),
982                display_id: "main".to_string(),
983                latest_commit: "def456".to_string(),
984                repository: create_test_repository(),
985            },
986            locked: false,
987            author: create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved),
988            links: PullRequestLinks {
989                self_link: vec![SelfLink {
990                    href: format!(
991                        "http://bitbucket.local/projects/TEST/repos/test/pull-requests/{id}"
992                    ),
993                }],
994            },
995        }
996    }
997
998    fn create_test_repository() -> Repository {
999        Repository {
1000            id: 1,
1001            name: "test-repo".to_string(),
1002            slug: "test-repo".to_string(),
1003            scm_id: "git".to_string(),
1004            state: "AVAILABLE".to_string(),
1005            status_message: Some("Available".to_string()),
1006            forkable: true,
1007            project: Project {
1008                id: 1,
1009                key: "TEST".to_string(),
1010                name: "Test Project".to_string(),
1011                description: Some("Test project description".to_string()),
1012                public: false,
1013                project_type: "NORMAL".to_string(),
1014            },
1015            public: false,
1016        }
1017    }
1018
1019    fn create_test_participant(role: ParticipantRole, status: ParticipantStatus) -> Participant {
1020        Participant {
1021            user: User {
1022                name: "testuser".to_string(),
1023                display_name: Some("Test User".to_string()),
1024                email_address: Some("test@example.com".to_string()),
1025                active: true,
1026                slug: Some("testuser".to_string()),
1027            },
1028            role,
1029            approved: status == ParticipantStatus::Approved,
1030            status,
1031        }
1032    }
1033
1034    fn create_test_build_status(state: BuildState) -> BuildStatus {
1035        BuildStatus {
1036            state,
1037            url: Some("http://ci.example.com/build/123".to_string()),
1038            description: Some("Test build".to_string()),
1039            context: Some("CI/CD".to_string()),
1040        }
1041    }
1042
1043    #[test]
1044    fn test_pull_request_state_serialization() {
1045        assert_eq!(PullRequestState::Open.as_str(), "OPEN");
1046        assert_eq!(PullRequestState::Merged.as_str(), "MERGED");
1047        assert_eq!(PullRequestState::Declined.as_str(), "DECLINED");
1048    }
1049
1050    #[test]
1051    fn test_pull_request_is_open() {
1052        let open_pr = create_test_pull_request(1, PullRequestState::Open);
1053        assert!(open_pr.is_open());
1054
1055        let merged_pr = create_test_pull_request(2, PullRequestState::Merged);
1056        assert!(!merged_pr.is_open());
1057
1058        let declined_pr = create_test_pull_request(3, PullRequestState::Declined);
1059        assert!(!declined_pr.is_open());
1060    }
1061
1062    #[test]
1063    fn test_pull_request_web_url() {
1064        let pr = create_test_pull_request(123, PullRequestState::Open);
1065        let url = pr.web_url();
1066        assert!(url.is_some());
1067        assert_eq!(
1068            url.unwrap(),
1069            "http://bitbucket.local/projects/TEST/repos/test/pull-requests/123"
1070        );
1071    }
1072
1073    #[test]
1074    fn test_merge_strategy_conversion() {
1075        let squash = MergeStrategy::Squash;
1076        let merge = MergeStrategy::Merge;
1077        let ff = MergeStrategy::FastForward;
1078
1079        // Test that strategies can be created and compared
1080        assert!(matches!(squash, MergeStrategy::Squash));
1081        assert!(matches!(merge, MergeStrategy::Merge));
1082        assert!(matches!(ff, MergeStrategy::FastForward));
1083    }
1084
1085    #[test]
1086    fn test_merge_strategy_commit_message() {
1087        let pr = create_test_pull_request(1, PullRequestState::Open);
1088
1089        let squash_strategy = MergeStrategy::Squash;
1090        let message = squash_strategy.get_commit_message(&pr);
1091        assert!(message.is_some());
1092        assert!(message.unwrap().contains("Test PR"));
1093
1094        let merge_strategy = MergeStrategy::Merge;
1095        let message = merge_strategy.get_commit_message(&pr);
1096        assert!(message.is_none()); // Merge strategy uses Bitbucket default
1097
1098        let ff_strategy = MergeStrategy::FastForward;
1099        let message = ff_strategy.get_commit_message(&pr);
1100        assert!(message.is_none()); // Fast-forward doesn't create new commit message
1101    }
1102
1103    #[test]
1104    fn test_auto_merge_conditions_default() {
1105        let conditions = AutoMergeConditions::default();
1106
1107        assert!(conditions.wait_for_builds); // Default is true for auto-merge safety
1108        assert_eq!(conditions.build_timeout.as_secs(), 1800); // 30 minutes
1109        assert!(conditions.allowed_authors.is_none());
1110        assert!(matches!(conditions.merge_strategy, MergeStrategy::Squash));
1111    }
1112
1113    #[test]
1114    fn test_auto_merge_conditions_custom() {
1115        let conditions = AutoMergeConditions {
1116            merge_strategy: MergeStrategy::Merge,
1117            wait_for_builds: false,
1118            build_timeout: Duration::from_secs(3600),
1119            allowed_authors: Some(vec!["trusted-user".to_string()]),
1120        };
1121
1122        assert!(matches!(conditions.merge_strategy, MergeStrategy::Merge));
1123        assert!(!conditions.wait_for_builds);
1124        assert_eq!(conditions.build_timeout.as_secs(), 3600);
1125        assert!(conditions.allowed_authors.is_some());
1126    }
1127
1128    #[test]
1129    fn test_pull_request_status_ready_to_land() {
1130        let pr = create_test_pull_request(1, PullRequestState::Open);
1131        let participants = vec![create_test_participant(
1132            ParticipantRole::Reviewer,
1133            ParticipantStatus::Approved,
1134        )];
1135        let review_status = ReviewStatus {
1136            required_approvals: 1,
1137            current_approvals: 1,
1138            needs_work_count: 0,
1139            can_merge: true,
1140            missing_reviewers: vec![],
1141        };
1142
1143        let status = PullRequestStatus {
1144            pr,
1145            mergeable: Some(true),
1146            mergeable_details: None,
1147            participants,
1148            build_status: Some(create_test_build_status(BuildState::Successful)),
1149            review_status,
1150            conflicts: None,
1151        };
1152
1153        assert!(status.is_ready_to_land());
1154    }
1155
1156    #[test]
1157    fn test_pull_request_status_not_ready_to_land() {
1158        let pr = create_test_pull_request(1, PullRequestState::Open);
1159        let participants = vec![create_test_participant(
1160            ParticipantRole::Reviewer,
1161            ParticipantStatus::Unapproved,
1162        )];
1163        let review_status = ReviewStatus {
1164            required_approvals: 1,
1165            current_approvals: 0,
1166            needs_work_count: 0,
1167            can_merge: false,
1168            missing_reviewers: vec!["reviewer".to_string()],
1169        };
1170
1171        let status = PullRequestStatus {
1172            pr,
1173            mergeable: Some(false),
1174            mergeable_details: None,
1175            participants,
1176            build_status: Some(create_test_build_status(BuildState::Failed)),
1177            review_status,
1178            conflicts: Some(vec!["Conflict in file.txt".to_string()]),
1179        };
1180
1181        assert!(!status.is_ready_to_land());
1182    }
1183
1184    #[test]
1185    fn test_pull_request_status_blocking_reasons() {
1186        // Test PR with failed build
1187        let pr_status = PullRequestStatus {
1188            pr: create_test_pull_request(1, PullRequestState::Open),
1189            mergeable: Some(true),
1190            mergeable_details: None,
1191            participants: vec![create_test_participant(
1192                ParticipantRole::Author,
1193                ParticipantStatus::Approved,
1194            )],
1195            build_status: Some(create_test_build_status(BuildState::Failed)),
1196            review_status: ReviewStatus {
1197                required_approvals: 1,
1198                current_approvals: 0, // Needs approval
1199                needs_work_count: 0,
1200                can_merge: false,
1201                missing_reviewers: vec!["reviewer1".to_string()],
1202            },
1203            conflicts: None,
1204        };
1205
1206        let blocking_reasons = pr_status.get_blocking_reasons();
1207
1208        // Verify it detects multiple blocking reasons
1209        assert!(!blocking_reasons.is_empty());
1210
1211        // Check for build failure (actual format is "Build failed")
1212        assert!(blocking_reasons.iter().any(|r| r.contains("Build failed")));
1213
1214        // Check for approval requirement (now shows "No approvals yet" instead of specific count)
1215        assert!(blocking_reasons
1216            .iter()
1217            .any(|r| r.contains("No approvals yet")));
1218    }
1219
1220    #[test]
1221    fn test_pull_request_status_can_auto_merge() {
1222        let pr = create_test_pull_request(1, PullRequestState::Open);
1223        let participants = vec![create_test_participant(
1224            ParticipantRole::Reviewer,
1225            ParticipantStatus::Approved,
1226        )];
1227        let review_status = ReviewStatus {
1228            required_approvals: 1,
1229            current_approvals: 1,
1230            needs_work_count: 0,
1231            can_merge: true,
1232            missing_reviewers: vec![],
1233        };
1234
1235        let status = PullRequestStatus {
1236            pr,
1237            mergeable: Some(true),
1238            mergeable_details: None,
1239            participants,
1240            build_status: Some(create_test_build_status(BuildState::Successful)),
1241            review_status,
1242            conflicts: None,
1243        };
1244
1245        let conditions = AutoMergeConditions::default();
1246        assert!(status.can_auto_merge(&conditions));
1247
1248        // Test with author allowlist (should pass since PR author is "testuser")
1249        let allowlist_conditions = AutoMergeConditions {
1250            allowed_authors: Some(vec!["testuser".to_string()]),
1251            ..Default::default()
1252        };
1253        assert!(status.can_auto_merge(&allowlist_conditions));
1254
1255        // Test with non-matching mergeable state
1256        let mut status_not_mergeable = status.clone();
1257        status_not_mergeable.mergeable = Some(false);
1258        assert!(!status_not_mergeable.can_auto_merge(&conditions));
1259    }
1260
1261    #[test]
1262    fn test_build_state_variants() {
1263        // Test that all build states can be created
1264        let _successful = BuildState::Successful;
1265        let _failed = BuildState::Failed;
1266        let _in_progress = BuildState::InProgress;
1267        let _cancelled = BuildState::Cancelled;
1268        let _unknown = BuildState::Unknown;
1269
1270        // Test works if it compiles
1271        // Test passes if we reach this point without errors
1272    }
1273
1274    #[test]
1275    fn test_review_status_calculations() {
1276        let review_status = ReviewStatus {
1277            required_approvals: 2,
1278            current_approvals: 1,
1279            needs_work_count: 0,
1280            can_merge: false,
1281            missing_reviewers: vec!["reviewer2".to_string()],
1282        };
1283
1284        assert_eq!(review_status.required_approvals, 2);
1285        assert_eq!(review_status.current_approvals, 1);
1286        assert_eq!(review_status.needs_work_count, 0);
1287        assert!(!review_status.can_merge);
1288        assert_eq!(review_status.missing_reviewers.len(), 1);
1289    }
1290
1291    #[test]
1292    fn test_auto_merge_result_variants() {
1293        let pr = create_test_pull_request(1, PullRequestState::Merged);
1294
1295        // Test successful merge result
1296        let merged_result = AutoMergeResult::Merged {
1297            pr: Box::new(pr.clone()),
1298            merge_strategy: MergeStrategy::Squash,
1299        };
1300        assert!(matches!(merged_result, AutoMergeResult::Merged { .. }));
1301
1302        // Test not ready result
1303        let not_ready_result = AutoMergeResult::NotReady {
1304            blocking_reasons: vec!["Missing approvals".to_string()],
1305        };
1306        assert!(matches!(not_ready_result, AutoMergeResult::NotReady { .. }));
1307
1308        // Test failed result
1309        let failed_result = AutoMergeResult::Failed {
1310            error: "Network error".to_string(),
1311        };
1312        assert!(matches!(failed_result, AutoMergeResult::Failed { .. }));
1313    }
1314
1315    #[test]
1316    fn test_participant_roles_and_status() {
1317        let author = create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved);
1318        assert!(matches!(author.role, ParticipantRole::Author));
1319        assert!(author.approved);
1320
1321        let reviewer =
1322            create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::Unapproved);
1323        assert!(matches!(reviewer.role, ParticipantRole::Reviewer));
1324        assert!(!reviewer.approved);
1325
1326        let needs_work =
1327            create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::NeedsWork);
1328        assert!(matches!(needs_work.status, ParticipantStatus::NeedsWork));
1329        assert!(!needs_work.approved);
1330    }
1331
1332    #[test]
1333    fn test_polling_frequency_constant() {
1334        // Test that the polling frequency is 30 seconds as documented
1335        use std::time::Duration;
1336
1337        let polling_interval = Duration::from_secs(30);
1338        assert_eq!(polling_interval.as_secs(), 30);
1339
1340        // Verify it's reasonable (between 10 seconds and 1 minute)
1341        assert!(polling_interval.as_secs() >= 10);
1342        assert!(polling_interval.as_secs() <= 60);
1343    }
1344}