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