cascade_cli/bitbucket/
pull_request.rs

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