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 title = title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
263
264        let description = {
265            // Priority order: 1) Template (if configured), 2) User description, 3) Commit message body, 4) None
266            if let Some(template) = &self.config.cascade.pr_description_template {
267                Some(template.clone()) // Always use template if configured
268            } else if let Some(desc) = description {
269                Some(desc) // Use provided description if no template
270            } else if entry.message.lines().count() > 1 {
271                // Fallback to commit message body if no template and no description
272                Some(
273                    entry
274                        .message
275                        .lines()
276                        .skip(1)
277                        .collect::<Vec<_>>()
278                        .join("\n")
279                        .trim()
280                        .to_string(),
281                )
282            } else {
283                None
284            }
285        };
286
287        // Add stack hierarchy footer to description
288        let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
289
290        Ok(CreatePullRequestRequest {
291            title,
292            description: description_with_footer,
293            from_ref,
294            to_ref,
295            draft, // Explicitly set true or false for Bitbucket Server
296        })
297    }
298
299    /// Generate a beautiful stack hierarchy footer for PR descriptions
300    fn add_stack_hierarchy_footer(
301        &self,
302        description: Option<String>,
303        stack: &Stack,
304        current_entry: &StackEntry,
305    ) -> Result<Option<String>> {
306        let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
307
308        let footer = format!(
309            "\n\n---\n\n## 📚 Stack Information\n\n{}\n\n<sub>Generated by [Cascade CLI](https://github.com/your-org/cascade-cli) • Stack: **{}**</sub>",
310            hierarchy,
311            stack.name
312        );
313
314        match description {
315            Some(desc) => Ok(Some(format!("{desc}{footer}"))),
316            None => Ok(Some(footer.trim_start_matches('\n').to_string())),
317        }
318    }
319
320    /// Generate a visual hierarchy showing the stack structure
321    fn generate_stack_hierarchy(
322        &self,
323        stack: &Stack,
324        current_entry: &StackEntry,
325    ) -> Result<String> {
326        let mut hierarchy = String::new();
327
328        // Add stack overview
329        hierarchy.push_str(&format!("**Stack:** `{}`\n", stack.name));
330        hierarchy.push_str(&format!("**Base Branch:** `{}`\n", stack.base_branch));
331        hierarchy.push_str(&format!("**Total Entries:** {}\n\n", stack.entries.len()));
332
333        // Add visual tree
334        hierarchy.push_str("### 🌳 Stack Hierarchy\n\n");
335        hierarchy.push_str("```\n");
336
337        // Base branch
338        hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
339
340        // Stack entries with visual connections
341        for (index, entry) in stack.entries.iter().enumerate() {
342            let is_current = entry.id == current_entry.id;
343            let is_last = index == stack.entries.len() - 1;
344
345            // Visual tree connector
346            let connector = if is_last { "└── " } else { "├── " };
347
348            // Entry indicator
349            let indicator = if is_current {
350                "👈 YOU ARE HERE"
351            } else if entry.pull_request_id.is_some() {
352                "✅ PR Created"
353            } else {
354                "⏳ Not Submitted"
355            };
356
357            // PR link if available
358            let pr_info = if let Some(pr_id) = &entry.pull_request_id {
359                format!(" (PR #{pr_id})")
360            } else {
361                String::new()
362            };
363
364            hierarchy.push_str(&format!(
365                "{}🔗 {}{} {} {}\n",
366                connector,
367                entry.branch,
368                pr_info,
369                if is_current { "◀" } else { " " },
370                indicator
371            ));
372        }
373
374        hierarchy.push_str("```\n\n");
375
376        // Add current entry details
377        hierarchy.push_str("### 📋 Current Entry Details\n\n");
378        hierarchy.push_str(&format!("- **Branch:** `{}`\n", current_entry.branch));
379        hierarchy.push_str(&format!(
380            "- **Commit:** `{}`\n",
381            &current_entry.commit_hash[0..8]
382        ));
383
384        if let Some(pr_id) = &current_entry.pull_request_id {
385            hierarchy.push_str(&format!("- **Pull Request:** #{pr_id}\n"));
386        }
387
388        // Add navigation help
389        hierarchy.push_str("\n### 🧭 Navigation\n\n");
390        hierarchy.push_str("- **Previous Entry:** ");
391
392        if let Some(prev_index) = stack
393            .entries
394            .iter()
395            .position(|e| e.id == current_entry.id)
396            .and_then(|i| i.checked_sub(1))
397        {
398            let prev_entry = &stack.entries[prev_index];
399            if let Some(pr_id) = &prev_entry.pull_request_id {
400                // You would construct the actual Bitbucket URL here
401                hierarchy.push_str(&format!("`{}` (PR #{})", prev_entry.branch, pr_id));
402            } else {
403                hierarchy.push_str(&format!("`{}` (not submitted)", prev_entry.branch));
404            }
405        } else {
406            hierarchy.push_str(&format!("`{}` (base branch)", stack.base_branch));
407        }
408
409        hierarchy.push('\n');
410        hierarchy.push_str("- **Next Entry:** ");
411
412        if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
413            if current_index + 1 < stack.entries.len() {
414                let next_entry = &stack.entries[current_index + 1];
415                if let Some(pr_id) = &next_entry.pull_request_id {
416                    hierarchy.push_str(&format!("`{}` (PR #{})", next_entry.branch, pr_id));
417                } else {
418                    hierarchy.push_str(&format!("`{}` (not submitted)", next_entry.branch));
419                }
420            } else {
421                hierarchy.push_str("*This is the top of the stack*");
422            }
423        } else {
424            hierarchy.push_str("*Unknown*");
425        }
426
427        Ok(hierarchy)
428    }
429
430    /// Update pull requests after a rebase using smart force push strategy
431    /// This preserves all review history by updating existing branches instead of creating new ones
432    pub async fn update_prs_after_rebase(
433        &mut self,
434        stack_id: &Uuid,
435        branch_mapping: &HashMap<String, String>,
436    ) -> Result<Vec<String>> {
437        info!(
438            "Updating pull requests after rebase for stack {} using smart force push",
439            stack_id
440        );
441
442        let stack = self
443            .stack_manager
444            .get_stack(stack_id)
445            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
446            .clone();
447
448        let mut updated_branches = Vec::new();
449
450        for entry in &stack.entries {
451            // Check if this entry has an existing PR and was remapped to a new branch
452            if let (Some(pr_id_str), Some(new_branch)) =
453                (&entry.pull_request_id, branch_mapping.get(&entry.branch))
454            {
455                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
456                    info!(
457                        "Found existing PR #{} for entry {}, updating branch {} -> {}",
458                        pr_id, entry.id, entry.branch, new_branch
459                    );
460
461                    // Get the existing PR to understand its current state
462                    match self.pr_manager.get_pull_request(pr_id).await {
463                        Ok(_existing_pr) => {
464                            // Force push the new branch content to the old branch name
465                            // This preserves the PR while updating its contents
466                            match self
467                                .stack_manager
468                                .git_repo()
469                                .force_push_branch(&entry.branch, new_branch)
470                            {
471                                Ok(_) => {
472                                    info!(
473                                        "✅ Successfully force-pushed {} to preserve PR #{}",
474                                        entry.branch, pr_id
475                                    );
476
477                                    // Add a comment explaining the rebase
478                                    let rebase_comment = format!(
479                                        "🔄 **Automatic rebase completed**\n\n\
480                                        This PR has been automatically rebased to incorporate the latest changes.\n\
481                                        - Original commits: `{}`\n\
482                                        - New base: Latest main branch\n\
483                                        - All review history and comments are preserved\n\n\
484                                        The changes in this PR remain the same - only the base has been updated.",
485                                        &entry.commit_hash[..8]
486                                    );
487
488                                    if let Err(e) =
489                                        self.pr_manager.add_comment(pr_id, &rebase_comment).await
490                                    {
491                                        warn!(
492                                            "Failed to add rebase comment to PR #{}: {}",
493                                            pr_id, e
494                                        );
495                                    }
496
497                                    updated_branches.push(format!(
498                                        "PR #{}: {} (preserved)",
499                                        pr_id, entry.branch
500                                    ));
501                                }
502                                Err(e) => {
503                                    error!("Failed to force push {}: {}", entry.branch, e);
504                                    // Fall back to creating a comment about the issue
505                                    let error_comment = format!(
506                                        "⚠️ **Rebase Update Issue**\n\n\
507                                        The automatic rebase completed, but updating this PR failed.\n\
508                                        You may need to manually update this branch.\n\
509                                        Error: {e}"
510                                    );
511
512                                    if let Err(e2) =
513                                        self.pr_manager.add_comment(pr_id, &error_comment).await
514                                    {
515                                        warn!(
516                                            "Failed to add error comment to PR #{}: {}",
517                                            pr_id, e2
518                                        );
519                                    }
520                                }
521                            }
522                        }
523                        Err(e) => {
524                            warn!("Could not retrieve PR #{}: {}", pr_id, e);
525                        }
526                    }
527                }
528            } else if branch_mapping.contains_key(&entry.branch) {
529                // This entry was remapped but doesn't have a PR yet
530                info!(
531                    "Entry {} was remapped but has no PR - no action needed",
532                    entry.id
533                );
534            }
535        }
536
537        if !updated_branches.is_empty() {
538            info!(
539                "Successfully updated {} PRs using smart force push strategy",
540                updated_branches.len()
541            );
542        }
543
544        Ok(updated_branches)
545    }
546
547    /// Check the enhanced status of all pull requests in a stack
548    pub async fn check_enhanced_stack_status(
549        &self,
550        stack_id: &Uuid,
551    ) -> Result<StackSubmissionStatus> {
552        let stack = self
553            .stack_manager
554            .get_stack(stack_id)
555            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
556
557        let mut status = StackSubmissionStatus {
558            stack_name: stack.name.clone(),
559            total_entries: stack.entries.len(),
560            submitted_entries: 0,
561            open_prs: 0,
562            merged_prs: 0,
563            declined_prs: 0,
564            pull_requests: Vec::new(),
565            enhanced_statuses: Vec::new(),
566        };
567
568        for entry in &stack.entries {
569            if let Some(pr_id_str) = &entry.pull_request_id {
570                status.submitted_entries += 1;
571
572                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
573                    // Get enhanced status instead of basic PR
574                    match self.pr_manager.get_pull_request_status(pr_id).await {
575                        Ok(enhanced_status) => {
576                            match enhanced_status.pr.state {
577                                crate::bitbucket::pull_request::PullRequestState::Open => {
578                                    status.open_prs += 1
579                                }
580                                crate::bitbucket::pull_request::PullRequestState::Merged => {
581                                    status.merged_prs += 1
582                                }
583                                crate::bitbucket::pull_request::PullRequestState::Declined => {
584                                    status.declined_prs += 1
585                                }
586                            }
587                            status.pull_requests.push(enhanced_status.pr.clone());
588                            status.enhanced_statuses.push(enhanced_status);
589                        }
590                        Err(e) => {
591                            warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
592                            // Fallback to basic PR info
593                            match self.pr_manager.get_pull_request(pr_id).await {
594                                Ok(pr) => {
595                                    match pr.state {
596                                        crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
597                                        crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
598                                        crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
599                                    }
600                                    status.pull_requests.push(pr);
601                                }
602                                Err(e2) => {
603                                    warn!("Failed to get basic PR #{}: {}", pr_id, e2);
604                                }
605                            }
606                        }
607                    }
608                }
609            }
610        }
611
612        Ok(status)
613    }
614}
615
616/// Status of stack submission with enhanced mergability information
617#[derive(Debug)]
618pub struct StackSubmissionStatus {
619    pub stack_name: String,
620    pub total_entries: usize,
621    pub submitted_entries: usize,
622    pub open_prs: usize,
623    pub merged_prs: usize,
624    pub declined_prs: usize,
625    pub pull_requests: Vec<PullRequest>,
626    pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
627}
628
629impl StackSubmissionStatus {
630    /// Calculate completion percentage
631    pub fn completion_percentage(&self) -> f64 {
632        if self.total_entries == 0 {
633            0.0
634        } else {
635            (self.merged_prs as f64 / self.total_entries as f64) * 100.0
636        }
637    }
638
639    /// Check if all entries are submitted
640    pub fn all_submitted(&self) -> bool {
641        self.submitted_entries == self.total_entries
642    }
643
644    /// Check if all PRs are merged
645    pub fn all_merged(&self) -> bool {
646        self.merged_prs == self.total_entries
647    }
648}