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