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