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