cascade_cli/bitbucket/
integration.rs

1use crate::bitbucket::client::BitbucketClient;
2use crate::bitbucket::pull_request::{
3    CreatePullRequestRequest, Project, PullRequest, PullRequestManager, PullRequestRef,
4    PullRequestState, Repository,
5};
6use crate::cli::output::Output;
7use crate::config::CascadeConfig;
8use crate::errors::{CascadeError, Result};
9use crate::stack::{Stack, StackEntry, StackManager};
10use std::collections::HashMap;
11use tracing::{error, info, warn};
12use uuid::Uuid;
13
14/// High-level integration between stacks and Bitbucket
15pub struct BitbucketIntegration {
16    stack_manager: StackManager,
17    pr_manager: PullRequestManager,
18    config: CascadeConfig,
19}
20
21impl BitbucketIntegration {
22    /// Create a new Bitbucket integration
23    pub fn new(stack_manager: StackManager, config: CascadeConfig) -> Result<Self> {
24        let bitbucket_config = config
25            .bitbucket
26            .as_ref()
27            .ok_or_else(|| CascadeError::config("Bitbucket configuration not found"))?;
28
29        let client = BitbucketClient::new(bitbucket_config)?;
30        let pr_manager = PullRequestManager::new(client);
31
32        Ok(Self {
33            stack_manager,
34            pr_manager,
35            config,
36        })
37    }
38
39    /// Update all PR descriptions in a stack with current hierarchy
40    pub async fn update_all_pr_descriptions(&self, stack_id: &Uuid) -> Result<Vec<u64>> {
41        let stack = self
42            .stack_manager
43            .get_stack(stack_id)
44            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
45
46        let mut updated_prs = Vec::new();
47
48        // Update each PR with current stack hierarchy
49        for entry in &stack.entries {
50            if let Some(pr_id_str) = &entry.pull_request_id {
51                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
52                    // Get current PR to get its version
53                    match self.pr_manager.get_pull_request(pr_id).await {
54                        Ok(pr) => {
55                            // Generate updated description with current stack state
56                            let updated_description = self.add_stack_hierarchy_footer(
57                                pr.description.clone().and_then(|desc| {
58                                    // Remove old stack hierarchy if present
59                                    desc.split("---\n\n## 📚 Stack:")
60                                        .next()
61                                        .map(|s| s.trim().to_string())
62                                }),
63                                stack,
64                                entry,
65                            )?;
66
67                            // Update the PR description
68                            match self
69                                .pr_manager
70                                .update_pull_request(
71                                    pr_id,
72                                    None, // Don't change title
73                                    updated_description,
74                                    pr.version,
75                                )
76                                .await
77                            {
78                                Ok(_) => {
79                                    updated_prs.push(pr_id);
80                                }
81                                Err(e) => {
82                                    warn!("Failed to update PR #{}: {}", pr_id, e);
83                                }
84                            }
85                        }
86                        Err(e) => {
87                            warn!("Failed to get PR #{} for update: {}", pr_id, e);
88                        }
89                    }
90                }
91            }
92        }
93
94        Ok(updated_prs)
95    }
96
97    /// Submit a single stack entry as a pull request
98    pub async fn submit_entry(
99        &mut self,
100        stack_id: &Uuid,
101        entry_id: &Uuid,
102        title: Option<String>,
103        description: Option<String>,
104        draft: bool,
105    ) -> Result<PullRequest> {
106        let stack = self
107            .stack_manager
108            .get_stack(stack_id)
109            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
110
111        let entry = stack
112            .get_entry(entry_id)
113            .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found in stack")))?;
114
115        // Submitting stack entry as pull request
116
117        // 🆕 VALIDATE GIT INTEGRITY BEFORE SUBMISSION
118        if let Err(integrity_error) = stack.validate_git_integrity(self.stack_manager.git_repo()) {
119            return Err(CascadeError::validation(format!(
120                "Cannot submit entry from corrupted stack '{}':\n{}",
121                stack.name, integrity_error
122            )));
123        }
124
125        // Push branch to remote
126        let git_repo = self.stack_manager.git_repo();
127
128        // If entry already has a PR, force-push to update it (standard stacked diff workflow)
129        // Otherwise, regular push for new submissions
130        if entry.pull_request_id.is_some() {
131            // Force push for existing PRs (after rebase/amend)
132            git_repo
133                .force_push_single_branch(&entry.branch)
134                .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
135        } else {
136            // Regular push for new submissions
137            git_repo
138                .push(&entry.branch)
139                .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
140        }
141
142        // Branch pushed successfully
143
144        // Mark as pushed in metadata
145        if let Some(commit_meta) = self
146            .stack_manager
147            .get_repository_metadata()
148            .commits
149            .get(&entry.commit_hash)
150        {
151            let mut updated_meta = commit_meta.clone();
152            updated_meta.mark_pushed();
153            // Note: This would require making mark_pushed public and updating the metadata
154            // For now, we'll track this as a future enhancement
155        }
156
157        // Determine target branch (parent entry's branch or stack base)
158        let target_branch = self.get_target_branch(stack, entry)?;
159
160        // Ensure target branch is also pushed to remote (if it's not the base branch)
161        if target_branch != stack.base_branch {
162            // Ensure target branch is pushed to remote
163
164            // Push target branch - fail fast if this fails
165            git_repo.push(&target_branch).map_err(|e| {
166                CascadeError::bitbucket(format!(
167                    "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
168                    Try manually pushing with: git push origin {target_branch}"
169                ))
170            })?;
171
172            // Target branch pushed successfully
173        }
174
175        // Create pull request
176        let pr_request =
177            self.create_pr_request(stack, entry, &target_branch, title, description, draft)?;
178
179        let pr = match self.pr_manager.create_pull_request(pr_request).await {
180            Ok(pr) => pr,
181            Err(e) => {
182                return Err(CascadeError::bitbucket(format!(
183                    "Failed to create pull request for branch '{}' -> '{}': {}. \
184                    Ensure both branches exist in the remote repository. \
185                    You can manually push with: git push origin {}",
186                    entry.branch, target_branch, e, entry.branch
187                )));
188            }
189        };
190
191        // Update stack manager with PR information
192        self.stack_manager
193            .submit_entry(stack_id, entry_id, pr.id.to_string())?;
194
195        // Pull request created for entry
196        Ok(pr)
197    }
198
199    /// Check the status of all pull requests in a stack
200    pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
201        let stack = self
202            .stack_manager
203            .get_stack(stack_id)
204            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
205
206        let mut status = StackSubmissionStatus {
207            stack_name: stack.name.clone(),
208            total_entries: stack.entries.len(),
209            submitted_entries: 0,
210            open_prs: 0,
211            merged_prs: 0,
212            declined_prs: 0,
213            pull_requests: Vec::new(),
214            enhanced_statuses: Vec::new(),
215        };
216
217        for entry in &stack.entries {
218            if let Some(pr_id_str) = &entry.pull_request_id {
219                status.submitted_entries += 1;
220
221                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
222                    match self.pr_manager.get_pull_request(pr_id).await {
223                        Ok(pr) => {
224                            match pr.state {
225                                PullRequestState::Open => status.open_prs += 1,
226                                PullRequestState::Merged => status.merged_prs += 1,
227                                PullRequestState::Declined => status.declined_prs += 1,
228                            }
229                            status.pull_requests.push(pr);
230                        }
231                        Err(e) => {
232                            warn!("Failed to get pull request #{}: {}", pr_id, e);
233                        }
234                    }
235                }
236            }
237        }
238
239        Ok(status)
240    }
241
242    /// List all pull requests for the repository
243    pub async fn list_pull_requests(
244        &self,
245        state: Option<PullRequestState>,
246    ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
247        self.pr_manager.list_pull_requests(state).await
248    }
249
250    /// Get the target branch for a stack entry
251    fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
252        // For the first entry (bottom of stack), target is the base branch
253        if let Some(first_entry) = stack.entries.first() {
254            if entry.id == first_entry.id {
255                return Ok(stack.base_branch.clone());
256            }
257        }
258
259        // For other entries, find the parent entry's branch
260        let entry_index = stack
261            .entries
262            .iter()
263            .position(|e| e.id == entry.id)
264            .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
265
266        if entry_index == 0 {
267            Ok(stack.base_branch.clone())
268        } else {
269            Ok(stack.entries[entry_index - 1].branch.clone())
270        }
271    }
272
273    /// Create a pull request request object
274    fn create_pr_request(
275        &self,
276        stack: &Stack,
277        entry: &StackEntry,
278        target_branch: &str,
279        title: Option<String>,
280        description: Option<String>,
281        draft: bool,
282    ) -> Result<CreatePullRequestRequest> {
283        let bitbucket_config = self.config.bitbucket.as_ref()
284            .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
285
286        let repository = Repository {
287            id: 0, // This will be filled by the API
288            name: bitbucket_config.repo.clone(),
289            slug: bitbucket_config.repo.clone(),
290            scm_id: "git".to_string(),
291            state: "AVAILABLE".to_string(),
292            status_message: Some("Available".to_string()),
293            forkable: true,
294            project: Project {
295                id: 0,
296                key: bitbucket_config.project.clone(),
297                name: bitbucket_config.project.clone(),
298                description: None,
299                public: false,
300                project_type: "NORMAL".to_string(),
301            },
302            public: false,
303        };
304
305        let from_ref = PullRequestRef {
306            id: format!("refs/heads/{}", entry.branch),
307            display_id: entry.branch.clone(),
308            latest_commit: entry.commit_hash.clone(),
309            repository: repository.clone(),
310        };
311
312        let to_ref = PullRequestRef {
313            id: format!("refs/heads/{target_branch}"),
314            display_id: target_branch.to_string(),
315            latest_commit: "".to_string(), // This will be filled by the API
316            repository,
317        };
318
319        let mut title =
320            title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
321
322        // Add [DRAFT] prefix for draft PRs
323        if draft && !title.starts_with("[DRAFT]") {
324            title = format!("[DRAFT] {title}");
325        }
326
327        let description = {
328            // Priority order: 1) Template (if configured), 2) User description, 3) Commit message body, 4) None
329            if let Some(template) = &self.config.cascade.pr_description_template {
330                Some(template.clone()) // Always use template if configured
331            } else if let Some(desc) = description {
332                Some(desc) // Use provided description if no template
333            } else if entry.message.lines().count() > 1 {
334                // Fallback to commit message body if no template and no description
335                Some(
336                    entry
337                        .message
338                        .lines()
339                        .skip(1)
340                        .collect::<Vec<_>>()
341                        .join("\n")
342                        .trim()
343                        .to_string(),
344                )
345            } else {
346                None
347            }
348        };
349
350        // Add stack hierarchy footer to description
351        let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
352
353        Ok(CreatePullRequestRequest {
354            title,
355            description: description_with_footer,
356            from_ref,
357            to_ref,
358            draft, // Explicitly set true or false for Bitbucket Server
359        })
360    }
361
362    /// Generate a beautiful stack hierarchy footer for PR descriptions
363    fn add_stack_hierarchy_footer(
364        &self,
365        description: Option<String>,
366        stack: &Stack,
367        current_entry: &StackEntry,
368    ) -> Result<Option<String>> {
369        let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
370
371        let footer = format!("\n\n---\n\n## 📚 Stack: {}\n\n{}", stack.name, hierarchy);
372
373        match description {
374            Some(desc) => Ok(Some(format!("{desc}{footer}"))),
375            None => Ok(Some(footer.trim_start_matches('\n').to_string())),
376        }
377    }
378
379    /// Generate a visual hierarchy showing the stack structure
380    fn generate_stack_hierarchy(
381        &self,
382        stack: &Stack,
383        current_entry: &StackEntry,
384    ) -> Result<String> {
385        let mut hierarchy = String::new();
386
387        // Add visual tree directly without redundant info
388        hierarchy.push_str("### Stack Hierarchy\n\n");
389        hierarchy.push_str("```\n");
390
391        // Base branch
392        hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
393
394        // Stack entries with visual connections
395        for (index, entry) in stack.entries.iter().enumerate() {
396            let is_current = entry.id == current_entry.id;
397            let is_last = index == stack.entries.len() - 1;
398
399            // Visual tree connector
400            let connector = if is_last { "└── " } else { "├── " };
401
402            // Entry indicator
403            let indicator = if is_current {
404                "← current"
405            } else if entry.pull_request_id.is_some() {
406                ""
407            } else {
408                "(pending)"
409            };
410
411            // PR link if available
412            let pr_info = if let Some(pr_id) = &entry.pull_request_id {
413                format!(" (PR #{pr_id})")
414            } else {
415                String::new()
416            };
417
418            hierarchy.push_str(&format!(
419                "{}{}{} {}\n",
420                connector, entry.branch, pr_info, indicator
421            ));
422        }
423
424        hierarchy.push_str("```\n\n");
425
426        // Add position context
427        if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
428            let position = current_index + 1;
429            let total = stack.entries.len();
430            hierarchy.push_str(&format!("**Position:** {position} of {total} in stack"));
431        }
432
433        Ok(hierarchy)
434    }
435
436    /// Update pull requests after a rebase using smart force push strategy
437    /// This preserves all review history by updating existing branches instead of creating new ones
438    pub async fn update_prs_after_rebase(
439        &mut self,
440        stack_id: &Uuid,
441        branch_mapping: &HashMap<String, String>,
442    ) -> Result<Vec<String>> {
443        info!(
444            "Updating pull requests after rebase for stack {} using smart force push",
445            stack_id
446        );
447
448        let stack = self
449            .stack_manager
450            .get_stack(stack_id)
451            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
452            .clone();
453
454        let mut updated_branches = Vec::new();
455
456        for entry in &stack.entries {
457            // Check if this entry has an existing PR and was remapped to a new branch
458            if let (Some(pr_id_str), Some(new_branch)) =
459                (&entry.pull_request_id, branch_mapping.get(&entry.branch))
460            {
461                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
462                    info!(
463                        "Found existing PR #{} for entry {}, updating branch {} -> {}",
464                        pr_id, entry.id, entry.branch, new_branch
465                    );
466
467                    // Get the existing PR to understand its current state
468                    match self.pr_manager.get_pull_request(pr_id).await {
469                        Ok(_existing_pr) => {
470                            // Validate that the new branch contains cumulative changes
471                            if let Err(validation_error) =
472                                self.validate_cumulative_changes(&entry.branch, new_branch)
473                            {
474                                Output::error(format!(
475                                    "❌ Validation failed for PR #{pr_id}: {validation_error}"
476                                ));
477                                Output::warning("Skipping force push to prevent data loss");
478                                continue;
479                            }
480
481                            // Force push the new branch content to the old branch name
482                            // This preserves the PR while updating its contents
483                            match self
484                                .stack_manager
485                                .git_repo()
486                                .force_push_branch(&entry.branch, new_branch)
487                            {
488                                Ok(_) => {
489                                    info!(
490                                        "✅ Successfully force-pushed {} to preserve PR #{}",
491                                        entry.branch, pr_id
492                                    );
493
494                                    // Add a comment explaining the rebase
495                                    let rebase_comment = format!(
496                                        "🔄 **Automatic rebase completed**\n\n\
497                                        This PR has been automatically rebased to incorporate the latest changes.\n\
498                                        - Original commits: `{}`\n\
499                                        - New base: Latest main branch\n\
500                                        - All review history and comments are preserved\n\n\
501                                        The changes in this PR remain the same - only the base has been updated.",
502                                        &entry.commit_hash[..8]
503                                    );
504
505                                    if let Err(e) =
506                                        self.pr_manager.add_comment(pr_id, &rebase_comment).await
507                                    {
508                                        warn!(
509                                            "Failed to add rebase comment to PR #{}: {}",
510                                            pr_id, e
511                                        );
512                                    }
513
514                                    updated_branches.push(format!(
515                                        "PR #{}: {} (preserved)",
516                                        pr_id, entry.branch
517                                    ));
518                                }
519                                Err(e) => {
520                                    error!("Failed to force push {}: {}", entry.branch, e);
521                                    // Fall back to creating a comment about the issue
522                                    let error_comment = format!(
523                                        "⚠️ **Rebase Update Issue**\n\n\
524                                        The automatic rebase completed, but updating this PR failed.\n\
525                                        You may need to manually update this branch.\n\
526                                        Error: {e}"
527                                    );
528
529                                    if let Err(e2) =
530                                        self.pr_manager.add_comment(pr_id, &error_comment).await
531                                    {
532                                        warn!(
533                                            "Failed to add error comment to PR #{}: {}",
534                                            pr_id, e2
535                                        );
536                                    }
537                                }
538                            }
539                        }
540                        Err(e) => {
541                            warn!("Could not retrieve PR #{}: {}", pr_id, e);
542                        }
543                    }
544                }
545            } else if branch_mapping.contains_key(&entry.branch) {
546                // This entry was remapped but doesn't have a PR yet
547                info!(
548                    "Entry {} was remapped but has no PR - no action needed",
549                    entry.id
550                );
551            }
552        }
553
554        if !updated_branches.is_empty() {
555            info!(
556                "Successfully updated {} PRs using smart force push strategy",
557                updated_branches.len()
558            );
559        }
560
561        Ok(updated_branches)
562    }
563
564    /// Validate that the new branch contains cumulative changes from the base
565    /// This prevents data loss during force push operations
566    fn validate_cumulative_changes(&self, original_branch: &str, new_branch: &str) -> Result<()> {
567        let git_repo = self.stack_manager.git_repo();
568
569        // Get the stack that contains this branch
570        let stack = self
571            .stack_manager
572            .get_all_stacks()
573            .into_iter()
574            .find(|s| s.entries.iter().any(|e| e.branch == original_branch))
575            .ok_or_else(|| {
576                CascadeError::config(format!(
577                    "No stack found containing branch '{original_branch}'"
578                ))
579            })?;
580
581        let base_branch = &stack.base_branch;
582
583        // Check that the new branch contains all changes from base to original branch
584        match git_repo.get_commits_between(base_branch, new_branch) {
585            Ok(new_commits) => {
586                if new_commits.is_empty() {
587                    return Err(CascadeError::validation(format!(
588                        "New branch '{new_branch}' contains no commits from base '{base_branch}' - this would result in data loss"
589                    )));
590                }
591
592                // Verify that the new branch has at least as many commits as expected
593                match git_repo.get_commits_between(base_branch, original_branch) {
594                    Ok(original_commits) => {
595                        if new_commits.len() < original_commits.len() {
596                            return Err(CascadeError::validation(format!(
597                                "New branch '{}' has {} commits but original branch '{}' had {} commits - potential data loss",
598                                new_branch, new_commits.len(), original_branch, original_commits.len()
599                            )));
600                        }
601
602                        tracing::debug!(
603                            "Validation passed: new branch '{}' has {} commits, original had {} commits",
604                            new_branch, new_commits.len(), original_commits.len()
605                        );
606                        Ok(())
607                    }
608                    Err(e) => {
609                        tracing::warn!("Could not validate original branch commits: {}", e);
610                        // Continue with force push if we can't validate original (branch might not exist remotely)
611                        Ok(())
612                    }
613                }
614            }
615            Err(e) => Err(CascadeError::validation(format!(
616                "Could not get commits for validation: {e}"
617            ))),
618        }
619    }
620
621    /// Check the enhanced status of all pull requests in a stack
622    pub async fn check_enhanced_stack_status(
623        &self,
624        stack_id: &Uuid,
625    ) -> Result<StackSubmissionStatus> {
626        let stack = self
627            .stack_manager
628            .get_stack(stack_id)
629            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
630
631        let mut status = StackSubmissionStatus {
632            stack_name: stack.name.clone(),
633            total_entries: stack.entries.len(),
634            submitted_entries: 0,
635            open_prs: 0,
636            merged_prs: 0,
637            declined_prs: 0,
638            pull_requests: Vec::new(),
639            enhanced_statuses: Vec::new(),
640        };
641
642        for entry in &stack.entries {
643            if let Some(pr_id_str) = &entry.pull_request_id {
644                status.submitted_entries += 1;
645
646                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
647                    // Get enhanced status instead of basic PR
648                    match self.pr_manager.get_pull_request_status(pr_id).await {
649                        Ok(enhanced_status) => {
650                            match enhanced_status.pr.state {
651                                crate::bitbucket::pull_request::PullRequestState::Open => {
652                                    status.open_prs += 1
653                                }
654                                crate::bitbucket::pull_request::PullRequestState::Merged => {
655                                    status.merged_prs += 1
656                                }
657                                crate::bitbucket::pull_request::PullRequestState::Declined => {
658                                    status.declined_prs += 1
659                                }
660                            }
661                            status.pull_requests.push(enhanced_status.pr.clone());
662                            status.enhanced_statuses.push(enhanced_status);
663                        }
664                        Err(e) => {
665                            warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
666                            // Fallback to basic PR info
667                            match self.pr_manager.get_pull_request(pr_id).await {
668                                Ok(pr) => {
669                                    match pr.state {
670                                        crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
671                                        crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
672                                        crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
673                                    }
674                                    status.pull_requests.push(pr);
675                                }
676                                Err(e2) => {
677                                    warn!("Failed to get basic PR #{}: {}", pr_id, e2);
678                                }
679                            }
680                        }
681                    }
682                }
683            }
684        }
685
686        Ok(status)
687    }
688}
689
690/// Status of stack submission with enhanced mergability information
691#[derive(Debug)]
692pub struct StackSubmissionStatus {
693    pub stack_name: String,
694    pub total_entries: usize,
695    pub submitted_entries: usize,
696    pub open_prs: usize,
697    pub merged_prs: usize,
698    pub declined_prs: usize,
699    pub pull_requests: Vec<PullRequest>,
700    pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
701}
702
703impl StackSubmissionStatus {
704    /// Calculate completion percentage
705    pub fn completion_percentage(&self) -> f64 {
706        if self.total_entries == 0 {
707            0.0
708        } else {
709            (self.merged_prs as f64 / self.total_entries as f64) * 100.0
710        }
711    }
712
713    /// Check if all entries are submitted
714    pub fn all_submitted(&self) -> bool {
715        self.submitted_entries == self.total_entries
716    }
717
718    /// Check if all PRs are merged
719    pub fn all_merged(&self) -> bool {
720        self.merged_prs == self.total_entries
721    }
722}