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