Skip to main content

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