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