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